diff --git a/ChangeLog b/ChangeLog index 3855d263..c204105e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,12 @@ ## Interface and handling +- reorganize GUI parameters, add conditional seed/accessibility controls and + grouped CSV defaults, expand FASTA limits and refine result headers (PR #299) + +- retain computed GUI runs as named, foldable sessions with JSON input restoration, + independent outputs, per-session ZIP export and deletion (PR #297 review) + - permit clearing inherited CSV sorting when replaying JSON as text reports; validate JSON/GUI behavior in native release/debug and real browsers (#287) @@ -279,6 +285,40 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * gui/{app,options,columns}.js, gui/{index.html,styles.css} + * organize tabs/rows as Sequences, Seed, Output, Model & Energy, Query, + Target and CLI-only; align wrapped labels and place Compute first + + add a three-way seed selector, synchronized accessibility flags and + mode-dependent input availability; place SHAPE beside accessibility + * group compact CSV choices with descriptions and group selection actions; + default to 10 predictions and expanded task-specific energy columns, + explicitly preserving browser defaults in native-compatible JSON + * gui/{protocol,uploads,parameter-file}.js, gui/examples/ + * accept 50 FASTA records per strand of 2-5,000 nt each, with longest + selected query x longest selected target <= 1,000,000; allow 1 MB FASTA + + warn about ignored FASTA header descriptions while using first-token IDs + * gui/{sessions,results}.js + * select results from the header/name, order Show/Hide before the name and + ZIP/delete actions, compact table headings and distinguish selected rows + * gui/tests/, scripts/wasm/test-browser.py, README.md, gui/docs/ + + cover limits, native JSON replay/defaults, dependent controls, grouped CSV + choices, desktop/mobile alignment and result header selection + * address https://github.com/BackofenLab/IntaRNA/pull/299#issuecomment-6375752155 + +261023 Alexander Mitrofanov + * gui/{app,sessions,results,visualization}.js, gui/{index.html,styles.css} + + retain successful runs in browser memory with unique IDs, editable names, + independent results/diagnostics and JSON snapshots of their computed inputs + * restore inputs and fold other output areas on selection; preserve sessions + across edits, reset, imports, failures and aborts; unload folded viewers + + export each session's original JSON/outputs from its header and delete it + without overwriting editable inputs; keep controls keyboard accessible + * scripts/wasm/{stage.sh,test-browser.py}, README.md, gui/docs/ + * stage the sessions module, exercise session lifetime and archive isolation + in real browsers, and document temporary storage and updated reset behavior + * follow-up to https://github.com/BackofenLab/IntaRNA/pull/297#issuecomment-6367326708 + 261023 Alexander Mitrofanov * .gitattributes, gui/docs/json-workflow.md + Preserve exact upstream license whitespace and mark the JSON header and diff --git a/README.md b/README.md index 4748d01a..ce94edbd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ seeding of interaction sites** Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). -Select an interaction to view it in vaRRI, or download a ZIP with inputs, compiled versions, reproducible parameters and all outputs. +The browser accepts up to 50 records per strand, 2–5,000 nucleotides each, with longest selected query × longest selected target at most 1,000,000. Prediction defaults to 10 interactions per pair and includes opening and seed energies; evaluation selects all energy columns. +Computed runs stay in separate, foldable sessions under Results in the current tab. Name and select sessions to restore their inputs and compare results; editing or resetting parameters keeps earlier runs. +Select an interaction to view it in vaRRI, or download a session’s ZIP with inputs, compiled versions, reproducible parameters and all outputs. Sessions are temporary and are cleared when the page is reloaded. See the [WebAssembly build instructions](gui/docs/step1-wasm-compilation.md) and [interface/deployment notes](gui/docs/step4-results-and-deployment.md). diff --git a/gui/app.js b/gui/app.js index 6889c1ce..446a7f60 100644 --- a/gui/app.js +++ b/gui/app.js @@ -1,9 +1,9 @@ import { parseFasta, prepareRequest, CSV_COLUMNS } from './protocol.js'; -import { GROUPS, FIELDS, EXTRA_OUTPUTS } from './options.js'; -import { CSV_CATALOG } from './columns.js'; +import { GROUPS, FIELDS, EXTRA_OUTPUTS, UI_DEFAULTS } from './options.js'; +import { CSV_CATALOG, CSV_GROUPS, defaultColumns } from './columns.js'; import { PredictionRunner } from './runner.js'; -import { ResultsView, downloadText, downloadBlob } from './results.js'; -import { createArchive } from './archive.js'; +import { downloadText } from './results.js'; +import { SessionsView } from './sessions.js'; import { importParameters, serializeParameters, MAX_PARAMETER_BYTES } from './parameter-file.js'; import { readFastaFile } from './uploads.js'; @@ -12,9 +12,10 @@ const form = byId('prediction-form'), runner = new PredictionRunner(); const getTask = () => form.querySelector('input[name=task]:checked').value; const setTask = (task) => { for (const input of form.querySelectorAll('input[name=task]')) input.checked = input.value === task; }; const dialog = byId('loading'), status = byId('result-status'), errorBox = byId('input-error'); -const results = new ResultsView(byId('results-view')); +const sessions = new SessionsView(byId('sessions'), byId('session-template'), (request, name) => { + applyParameters(request, `${name} selected. Its computed inputs have been restored.`); +}, showInputError, (message) => { status.textContent = message; }); const csvInputs = [], outputInputs = []; -let completedRun = null; let inputRevision = 0; let csvOrder = CSV_COLUMNS.split(','); function element(tag, text, className) { @@ -43,7 +44,7 @@ function group(key, label) { const body = element('div', '', 'group-body'); panel.append(body); byId('parameter-groups').append(panel); addTab(key, label); return body; } -addTab('sequence-input', 'Sequence input'); +addTab('sequence-input', 'Sequences'); byId('parameter-tabs').addEventListener('keydown', (event) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; const tabs = [...byId('parameter-tabs').children].filter((tab) => !tab.hidden); @@ -51,34 +52,67 @@ byId('parameter-tabs').addEventListener('keydown', (event) => { const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; event.preventDefault(); activateGroup(tabs[next].getAttribute('aria-controls'), true); }); +// Render each requested row explicitly; labels share a grid row so wrapped +// labels do not push neighboring controls out of alignment. +function renderField(field, id = field.key) { + const wrapper = element('div', '', `field${field.type === 'checkbox' ? ' check' : ''}`); + const label = element('label', field.label); label.htmlFor = id; label.title = `--${field.key}`; + const input = element(field.type === 'select' ? 'select' : field.type === 'textarea' ? 'textarea' : 'input'); input.id = id; + if (input.tagName === 'INPUT') input.type = field.type; + if (field.type === 'select') for (const [value, name] of Object.entries(field.choices)) { const option = element('option', name); option.value = value; input.append(option); } + if (field.type === 'number') { input.min = field.min; input.max = field.max; input.step = field.step ?? 1; } + if (field.type === 'checkbox') { input.checked = field.value; wrapper.append(input, label); } + else { input.value = field.value; wrapper.append(label, input); } + if (field.hint) { const hint = element('p', field.hint, 'field-hint'); hint.id = `${id}-help`; input.setAttribute('aria-describedby', hint.id); wrapper.append(hint); } + if (field.type === 'textarea') { + input.spellcheck = false; + const upload = element('input'); upload.type = 'file'; upload.accept = '.txt,.csv,.shape,.dat'; upload.setAttribute('aria-label', `Load ${field.label} from a local text file`); + upload.dataset.for = field.key; + upload.addEventListener('change', async () => { + const file = upload.files[0]; upload.value = ''; if (!file) return; + const revision = ++inputRevision; + try { + if (file.size > 1000000) throw new Error('Choose a text file of at most 1 MB.'); + const content = await file.text(); + if (inputRevision !== revision || input.disabled) return; + input.value = content; input.dispatchEvent(new Event('input', { bubbles: true })); + } catch (error) { if (inputRevision === revision) showInputError(error); } + }); wrapper.append(upload); + } + if (field.key === 'noSeed') wrapper.hidden = true; + return wrapper; +} +function fieldRow(body, fields, suffix = '') { + const grid = element('div', '', `field-grid columns-${fields.length}`); + for (const field of fields) grid.append(renderField(field, field.key + suffix)); + body.append(grid); return grid; +} for (const definition of GROUPS) { - const body = group(definition.key, definition.label), grid = element('div', '', 'field-grid'); body.append(grid); - for (const field of definition.fields) { - const wrapper = element('div', '', `field${field.type === 'checkbox' ? ' check' : ''}`); - const label = element('label', field.label); label.htmlFor = field.key; label.title = `--${field.key}`; - const input = element(field.type === 'select' ? 'select' : field.type === 'textarea' ? 'textarea' : 'input'); input.id = field.key; - if (input.tagName === 'INPUT') input.type = field.type; - if (field.type === 'select') for (const [value, name] of Object.entries(field.choices)) { const option = element('option', name); option.value = value; input.append(option); } - if (field.type === 'number') { input.min = field.min; input.max = field.max; input.step = field.step ?? 1; } - if (field.type === 'checkbox') { input.checked = field.value; wrapper.append(input, label); } - else { input.value = field.value; wrapper.append(label, input); } - if (field.hint) { const hint = element('p', field.hint, 'field-hint'); hint.id = `${field.key}-help`; input.setAttribute('aria-describedby', hint.id); wrapper.append(hint); } - if (field.type === 'textarea') { - input.spellcheck = false; - const upload = element('input'); upload.type = 'file'; upload.accept = '.txt,.csv,.shape,.dat'; upload.setAttribute('aria-label', `Load ${field.label} from a local text file`); - upload.dataset.for = field.key; - upload.addEventListener('change', async () => { - const file = upload.files[0]; upload.value = ''; if (!file) return; - const revision = ++inputRevision; - try { - if (file.size > 1000000) throw new Error('Choose a text file of at most 1 MB.'); - const content = await file.text(); - if (inputRevision !== revision) return; - input.value = content; input.dispatchEvent(new Event('input', { bubbles: true })); - } catch (error) { if (inputRevision === revision) showInputError(error); } - }); wrapper.append(upload); + const body = group(definition.key, definition.label); + if (definition.key === 'seed-options') { + const wrapper = element('div', '', 'field seed-mode'); + const label = element('label', 'Seed constraint'); label.htmlFor = 'seed-mode'; + const select = element('select'); select.id = 'seed-mode'; + for (const [value, name] of [['none', 'Disable seed constraint'], ['explicit', 'Explicit seed'], ['identify', 'Identify seeds']]) { + const option = element('option', name); option.value = value; select.append(option); } - grid.append(wrapper); + select.value = 'identify'; wrapper.append(label, select); body.append(wrapper); + } + if (definition.rows) { + for (const row of definition.rows) fieldRow(body, row); + } else fieldRow(body, definition.fields); + if (definition.strand) { + const p = definition.strand; + const shared = FIELDS.filter((field) => ['accNoLP', 'accNoGUend'].includes(field.key)); + fieldRow(body, shared, p === 't' ? '-target' : ''); + body.append(element('p', 'These two accessibility settings apply to both strands.', 'input-help')); + const columns = element('div', '', 'accessibility-columns'); body.append(columns); + const accessibility = element('div'), shape = element('div'); columns.append(accessibility, shape); + const field = (key) => FIELDS.find((entry) => entry.key === p + key); + fieldRow(accessibility, [field('AccConstr')]); + fieldRow(accessibility, [field('AccFile')]); + fieldRow(shape, [field('Shape')]); + fieldRow(shape, [field('ShapeMethod'), field('ShapeConversion')]); } } function checkbox(container, key, labelText, description) { @@ -88,31 +122,51 @@ function checkbox(container, key, labelText, description) { } const outputBody = byId('output-options').querySelector('.group-body'); outputBody.append(element('h3', 'CSV output columns', 'subheading')); -outputBody.append(element('p', 'seq1 is the target; seq2 is the query. Select columns for both the table and CSV download. You can hide table columns after computation without changing the CSV.', 'input-help')); +outputBody.append(element('p', 'seq1 is the target; seq2 is the query. Hover over a column name for details. Select columns for the table and CSV download; table visibility can be changed after computation.', 'input-help')); const selectionTools = element('div', '', 'selection-tools'); outputBody.append(selectionTools); -const csvPicker = element('div', '', 'checkbox-grid'); outputBody.append(csvPicker); -for (const column of [...CSV_COLUMNS.split(',').map((key) => CSV_CATALOG.find((column) => column.key === key)), ...CSV_CATALOG.filter((column) => !CSV_COLUMNS.split(',').includes(column.key))]) csvInputs.push(checkbox(csvPicker, column.key, `${column.key} — ${column.description}`, column.description)); -for (const [name, selection] of [['Select all', CSV_CATALOG.map((c) => c.key)], ['CLI defaults', CSV_COLUMNS.split(',')], ['Clear selection', []]]) { +const selectColumns = (selection) => { + csvOrder = [...selection]; for (const input of csvInputs) input.checked = selection.includes(input.value); inputsChanged(); +}; +for (const [name, selection] of [['Select all', () => CSV_CATALOG.map((c) => c.key)], ['Task defaults', () => defaultColumns(getTask())], ['Clear selection', () => []]]) { const button = element('button', name); button.type = 'button'; - button.addEventListener('click', () => { csvOrder = [...selection]; for (const input of csvInputs) input.checked = selection.includes(input.value); clearResults(); }); selectionTools.append(button); + button.addEventListener('click', () => selectColumns(selection())); selectionTools.append(button); +} +for (const group of CSV_GROUPS) { + const section = element('fieldset', '', 'csv-group'), legend = element('legend', group.label); + section.dataset.csvGroup = group.label; + for (const [name, checked] of [['Select all', true], ['Deselect all', false]]) { + const button = element('button', name, 'text-button'); button.type = 'button'; + button.setAttribute('aria-label', `${name} ${group.label} columns`); + button.addEventListener('click', () => { + for (const input of csvInputs.filter((input) => group.keys.includes(input.value))) input.checked = checked; + inputsChanged(); + }); legend.append(button); + } + const picker = element('div', '', 'checkbox-grid csv-columns'); section.append(legend, picker); outputBody.append(section); + for (const key of group.keys) { + const column = CSV_CATALOG.find((column) => column.key === key); + csvInputs.push(checkbox(picker, key, key, column.description)); + } } outputBody.append(element('h3', 'Additional output files', 'subheading')); outputBody.append(element('p', 'Reports are computed with the same settings. ASCII, detailed and ensemble reports each run a fresh engine instance and can take extra time.', 'input-help')); const outputPicker = element('div', '', 'checkbox-grid'); outputBody.append(outputPicker); for (const output of EXTRA_OUTPUTS) outputInputs.push(checkbox(outputPicker, output.key, output.label)); -const generalBody = group('general-options', 'General'); -generalBody.append(element('p', 'Tasks run locally in one worker. Abort or Escape stops computation immediately. Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); +const generalBody = group('general-options', 'CLI-only'); +generalBody.append(element('p', 'Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); const docs = element('a', 'Full CLI parameter documentation'); docs.href = 'https://github.com/BackofenLab/IntaRNA#readme'; generalBody.append(docs); -function clearResults(message = 'Inputs changed. Compute to update the interactions.') { +function inputsChanged(message = 'Inputs changed. Compute to update the interactions.') { inputRevision++; - completedRun = null; results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); + sessions.deselect(); byId('diagnostics').hidden = true; byId('diagnostic-text').textContent = ''; status.textContent = message; } function updateLength(name) { + const warning = byId(`${name}-warning`); warning.hidden = true; warning.textContent = ''; if (!byId(name).value.trim()) { byId(`${name}-length`).textContent = 'No sequence entered'; return; } try { const parsed = parseFasta(byId(name).value, name === 'query' ? 'Query' : 'Target', getTask() === 'prediction'); + warning.textContent = parsed.warnings.join(' '); warning.hidden = !parsed.warnings.length; byId(`${name}-length`).textContent = `${parsed.records.length} record${parsed.records.length === 1 ? '' : 's'}, ${parsed.records.reduce((sum, r) => sum + r.sequence.length, 0).toLocaleString()} nucleotides`; } catch { byId(`${name}-length`).textContent = 'Check sequence format and length'; } } @@ -129,9 +183,22 @@ function updateAvailability() { } for (const field of definition.fields) { const input = byId(field.key); - input.disabled = evaluation && !!field.prediction || !evaluation && definition.key === 'helix-options' && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; + input.disabled = evaluation && !!field.prediction || field.key.startsWith('helix') && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; + } + } + byId('noSeed').checked = byId('seed-mode').value === 'none'; + for (const field of FIELDS.filter((field) => field.key.startsWith('seed'))) { + byId(field.key).disabled = evaluation || (field.key === 'seedTQ' ? byId('seed-mode').value !== 'explicit' : byId('seed-mode').value !== 'identify'); + } + for (const p of ['q', 't']) { + const mode = byId(`${p}Acc`).value; + for (const key of ['AccW', 'AccL', 'PfScale', 'AccConstr', 'AccFile', 'Shape', 'ShapeMethod', 'ShapeConversion']) { + byId(p + key).disabled = mode === 'N' || ['AccW', 'AccL'].includes(key) && mode !== 'C' || key === 'AccFile' && !['P', 'E'].includes(mode); } + for (const key of ['accNoLP', 'accNoGUend']) byId(key + (p === 't' ? '-target' : '')).disabled = mode === 'N'; } + // Upload controls follow the text area they populate, including async reads. + for (const upload of form.querySelectorAll('input[data-for]')) upload.disabled = byId(upload.dataset.for).disabled; for (const input of outputInputs) { input.disabled = evaluation && EXTRA_OUTPUTS.find((entry) => entry.key === input.value).prediction; if (input.disabled) input.checked = false; } updateLength('query'); updateLength('target'); } @@ -142,18 +209,27 @@ function resetParameters({ clearInputs = false } = {}) { for (const input of form.querySelectorAll('input[type=file]')) input.value = ''; byId('examples-menu').open = false; activateGroup('sequence-input'); } - csvOrder = CSV_COLUMNS.split(','); - for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = field.value; else input.value = field.value; } - for (const input of csvInputs) input.checked = CSV_COLUMNS.split(',').includes(input.value); + csvOrder = defaultColumns(getTask()); + byId('seed-mode').value = 'identify'; + for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = UI_DEFAULTS[field.key]; else input.value = UI_DEFAULTS[field.key]; } + syncAccessibilityFlags(); + for (const input of csvInputs) input.checked = csvOrder.includes(input.value); for (const input of outputInputs) input.checked = false; byId('qIdxPos0').value = 1; byId('tIdxPos0').value = 1; byId('rri').value = ''; - updateAvailability(); errorBox.hidden = true; clearResults(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to CLI defaults. Compute to update the interactions.'); + updateAvailability(); errorBox.hidden = true; inputsChanged(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to task defaults. Compute to update the interactions.'); } byId('task').addEventListener('change', resetParameters); byId('reset').addEventListener('click', () => resetParameters({ clearInputs: true })); +function syncAccessibilityFlags(source) { + for (const key of ['accNoLP', 'accNoGUend']) { + if (source?.id === key + '-target') byId(key).checked = source.checked; + byId(key + '-target').checked = byId(key).checked; + } +} form.addEventListener('input', (event) => { if (event.target.type === 'file') return; - updateAvailability(); errorBox.hidden = true; clearResults(); + syncAccessibilityFlags(event.target); + updateAvailability(); errorBox.hidden = true; inputsChanged(); }); function showInputError(error) { errorBox.textContent = error.message; errorBox.hidden = false; @@ -168,10 +244,12 @@ function applyParameters(request, message = 'Parameters loaded. Compute to analy if (field.type === 'checkbox') byId(field.key).checked = value; else byId(field.key).value = value; } + byId('seed-mode').value = request.parameters.noSeed ? 'none' : request.parameters.seedTQ ? 'explicit' : 'identify'; + syncAccessibilityFlags(); csvOrder = [...request.csvColumns]; for (const input of csvInputs) input.checked = request.csvColumns.includes(input.value); for (const input of outputInputs) input.checked = request.outputs.includes(input.value); - updateAvailability(); activateGroup('sequence-input'); clearResults(message); + updateAvailability(); activateGroup('sequence-input'); inputsChanged(message); } for (const strand of ['query', 'target']) { const input = byId(strand), picker = byId(`${strand}-upload`); @@ -184,7 +262,7 @@ for (const strand of ['query', 'target']) { if (inputRevision !== revision) return; input.value = content; updateAvailability(); errorBox.hidden = true; - clearResults(`${strand === 'query' ? 'Query' : 'Target'} FASTA loaded. Compute to analyze the inputs.`); + inputsChanged(`${strand === 'query' ? 'Query' : 'Target'} FASTA loaded. Compute to analyze the inputs.`); } catch (error) { if (inputRevision === revision) showInputError(error); } }; byId(`${strand}-upload-button`).addEventListener('click', () => picker.click()); @@ -252,15 +330,16 @@ byId('examples-menu').addEventListener('keydown', (event) => { byId('abort').addEventListener('click', () => runner.abort()); dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); window.addEventListener('pagehide', () => runner.abort()); -function showFiles(files) { - for (const file of files) { - const details = element('details'), summary = element('summary', file.name), preview = element('pre', file.text.slice(0, 100000)); - const download = element('button', 'Download file', 'secondary'); download.type = 'button'; download.addEventListener('click', () => downloadText(file.name, file.text)); - details.append(summary, download, preview); - if (file.text.length > 100000) details.append(element('p', 'Preview limited to 100,000 characters. Download contains the complete file.')); - byId('file-list').append(details); - } - byId('output-files').hidden = !files.length; +function currentParameters() { + // Keep inactive edits in the form for toggling back, but exclude them from + // validation, saved JSON and native computation. Shared flags stay active + // whenever either strand uses accessibility. + return Object.fromEntries(FIELDS.map((field) => { + const input = byId(field.key); + const inactive = ['accNoLP', 'accNoGUend'].includes(field.key) + ? input.disabled && byId(field.key + '-target').disabled : input.disabled; + return [field.key, inactive ? field.value : field.type === 'checkbox' ? input.checked : input.value]; + })); } function currentRequest() { const selected = csvInputs.filter((input) => input.checked).map((input) => input.value); @@ -268,7 +347,7 @@ function currentRequest() { return { task: getTask(), query: byId('query').value, target: byId('target').value, qIdxPos0: byId('qIdxPos0').value, tIdxPos0: byId('tIdxPos0').value, rri: byId('rri').value, - parameters: Object.fromEntries(FIELDS.map((field) => [field.key, field.type === 'checkbox' ? byId(field.key).checked : byId(field.key).value])), + parameters: currentParameters(), csvColumns: columns, outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), }; } @@ -277,15 +356,16 @@ form.addEventListener('submit', async (event) => { const request = currentRequest(); errorBox.hidden = true; let prepared; - try { prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } - clearResults('Computing interactions…'); byId('loading-status').textContent = 'Loading the prediction engine…'; + try { + if (getTask() === 'prediction' && byId('seed-mode').value === 'explicit' && !request.parameters.seedTQ.trim()) throw new Error('Explicit seed: enter target&query dot-bar encodings.'); + prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } + inputsChanged('Computing interactions…'); byId('loading-status').textContent = 'Loading the prediction engine…'; byId('inputs').disabled = true; byId('results').setAttribute('aria-busy', 'true'); dialog.showModal(); try { const result = await runner.run(request, (message) => { byId('loading-status').textContent = message; }); - const count = results.show(result.csv, result.csvColumns, prepared); - status.textContent = count ? `${request.task === 'evaluation' ? 'Evaluation' : 'Prediction'} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && Number(request.parameters.outNumber) === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; - completedRun = { request: prepared, result }; - showFiles(result.files); byId('diagnostic-text').textContent = result.diagnostics; byId('diagnostics').hidden = !result.diagnostics; + // Keep the executed inputs and outputs together, independent of later edits. + const provenance = await buildInfo.catch(() => 'Compiled version information is unavailable.'); + status.textContent = sessions.add(prepared, result, provenance); } catch (error) { if (error.name === 'AbortError') status.textContent = 'Computation aborted. You can edit the sequences and try again.'; else { status.textContent = 'Computation failed.'; errorBox.textContent = error.message; errorBox.hidden = false; byId('diagnostic-text').textContent = error.diagnostics || ''; byId('diagnostics').hidden = !error.diagnostics; } @@ -304,15 +384,3 @@ fetch(new URL('./build-info.json', import.meta.url)).then((response) => { if (!response.ok) throw new Error('Missing build provenance'); return response.json(); }).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; } - -byId('download-zip').addEventListener('click', async () => { - const run = completedRun; if (!run) return; - const button = byId('download-zip'); button.disabled = true; - try { - const provenance = await buildInfo; - if (completedRun !== run) return; - downloadBlob('intarna-results.zip', new Blob([createArchive(run.request, run.result, provenance)], { type: 'application/zip' })); - } catch (error) { - errorBox.textContent = `ZIP download failed: ${error.message}. Reload the staged interface with its compiled version files.`; errorBox.hidden = false; - } finally { button.disabled = false; } -}); diff --git a/gui/columns.js b/gui/columns.js index 6b42b93a..9c096021 100644 --- a/gui/columns.js +++ b/gui/columns.js @@ -313,3 +313,23 @@ export const CSV_CATALOG = [ "numeric": true } ]; + +// Every native column appears once. Ensemble energies and their components are +// grouped with probabilities so users can see the cost/meaning of those outputs. +export const CSV_GROUPS = [ + { label: 'Energy', keys: ['E', 'ED1', 'ED2', 'E_init', 'E_loops', 'E_dangleL', 'E_dangleR', 'E_endL', 'E_endR', 'E_hybrid', 'E_norm', 'E_hybridNorm', 'E_add', 'seedE', 'seedED1', 'seedED2'] }, + { label: 'Output', keys: ['subseqDP', 'subseqDB', 'hybridDP', 'hybridDB', 'hybridDPfull', 'hybridDBfull', 'bpList'] }, + { label: 'Sequence', keys: ['id1', 'id2', 'seq1', 'seq2', 'subseq1', 'subseq2'] }, + { label: 'Interaction', keys: ['start1', 'end1', 'start2', 'end2', 'seedStart1', 'seedEnd1', 'seedStart2', 'seedEnd2'] }, + { label: 'Partition Function', keys: ['Pu1', 'Pu2', 'seedPu1', 'seedPu2', 'w', 'Etotal', 'Eall', 'Eall1', 'Eall2', 'Zall', 'Zall1', 'Zall2', 'EallTotal', 'P_E', 'RT'] }, +]; + +export function defaultColumns(task) { + const context = ['id1', 'start1', 'end1', 'id2', 'start2', 'end2', 'subseqDP', 'hybridDP', 'E']; + // Seed energies remain available in evaluation, where native output is NAN. + // List them explicitly: seedEnd1/2 are coordinates, not energy columns. + const extra = task === 'evaluation' + ? [...CSV_CATALOG.filter(({ key }) => key.startsWith('E')).map(({ key }) => key), 'seedE', 'seedED1', 'seedED2'] + : ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedStart2']; + return [...new Set([...context, ...extra])]; +} diff --git a/gui/docs/json-workflow.md b/gui/docs/json-workflow.md index f833e9e2..df5f7dfb 100644 --- a/gui/docs/json-workflow.md +++ b/gui/docs/json-workflow.md @@ -1,6 +1,7 @@ # JSON settings and reproducible runs -Load and Store sit to the right of Reset parameters. Store downloads +The action bar starts with Compute/Evaluate, followed by Input Parameters: +Examples, Store, Load and Reset. Store downloads `parameter.json` containing non-default settings and embedded input text in the [native JSON format](../../doc/json-parameters.md). `_comment` records compiled tool/library versions and executable reproduction commands. It is JSON metadata, @@ -26,11 +27,48 @@ and CSV-only settings while retaining the same inputs and analysis settings. Reports run independently, matching the worker. The WASM integration test replays the JSON in fresh engines and compares every output byte. +## Computed sessions + +Each successful computation, including a run with no interaction rows, creates a +new session with a unique ID within the page and a default name such as +`Prediction 1` or `Evaluation 2`. The session retains the computed inputs in JSON, +CSV output, additional files and diagnostics. Editing parameters, Load, examples, +Reset, invalid inputs, failed computations and cancellation preserve prior +sessions. Failed or aborted computations do not create a session. + +The newest session opens automatically under **Results**. Use **Show**, select +its header/name, or select its output area to restore that session’s +computed inputs and fold the other sessions. The task, sequence text, signed +indices, SHAPE/accessibility text, CSV selection/order and additional output +selection are restored together. **Hide** folds the output. Each session +keeps its own filter, sorting and visible columns; folded vaRRI frames are unloaded +and restored when their session is reopened. + +Edit **Session name** directly in the header. Names are plain text; an empty name +returns to the default. **Download ZIP** remains available while folded and always +archives that session’s original JSON and complete outputs, regardless of the +current form or table filters. The session name supplies a safe download filename; +ZIP members remain `parameter.json` and output files. The garbage-bin button +removes only its session and releases its output; it does not change form inputs. + +Sessions exist only in the current page’s browser memory. They are not written to +browser storage or uploaded. Reloading or closing the page discards them; use ZIP +exports to retain results. Memory use grows with the runs kept open, so delete +sessions that are no longer needed. Store continues to save the editable form. +If compiled metadata cannot be fetched, a completed run is still retained and its +JSON explicitly records that version information is unavailable. + ## Reset and examples -Reset clears sequences, interactions, uploaded files and results and restores -prediction defaults. Empty FASTA fields show the short demonstration sequences -as placeholders only. The Example disclosure menu sits to the left of Reset. +Reset clears the editable sequences, interactions and uploaded files and restores +prediction defaults (10 interactions per pair, expanded energy/seed columns). +Evaluation defaults select all energy columns. These GUI defaults are written +explicitly in JSON when they differ from the CLI; importing existing JSON still +uses native defaults for omitted parameters. Inactive controls keep their edits +in the form for switching modes, but their values are omitted from executed and +stored settings. Previously computed sessions remain available. Empty FASTA +fields show the short demonstration sequences as placeholders only. The Examples +disclosure menu follows the Input Parameters label. Maintainers edit `gui/examples/index.json` (an array of local JSON filenames). Each listed file contains `name`, `description`, and `configuration`, where the @@ -42,9 +80,13 @@ examples show an error in the menu; they never overwrite the form. ## Local FASTA files Each strand has an Upload button and accepts a text-file drop into its textarea. -Files are read locally (at most 60 KB), validated with the same FASTA checks as +Files are read locally (at most 1 MB), validated with the same FASTA checks as manual entry, and replace only that strand after successful validation. Prediction -accepts multi-FASTA; evaluation requires one record. Invalid files leave the +accepts up to 50 FASTA records per strand, each 2–5,000 nt; evaluation requires +one record. The longest selected query × longest selected target must be at most +1,000,000. Names end at the first whitespace; ignored header descriptions produce +a warning below the corresponding field. Both FASTA fields scroll horizontally +and vertically. Invalid files leave the previous input intact. A later edit, reset, import or computation discards any pending file read, so delayed reads cannot replace newer input. @@ -66,3 +108,42 @@ pending file read, so delayed reads cannot replace newer input. - Embedded ViennaRNA energy-parameter text matched native file input. Existing dependency-download and ViennaRNA-release tests, shell syntax, workflow YAML, source-distribution contents, vendor checksums and `git diff --check` passed. + +## Session follow-up validation (2026-10-23) + +- `npm test --prefix gui`: all 18 Node tests passed. +- `scripts/wasm/test-browser.py` passed in Chromium and Firefox with real WASM + at nested URLs. Session checks cover input/JSON restoration, task changes, + signed indices, SHAPE text, CSV order, reports, independent filters/columns, + edits/reset/imports, failed/aborted runs, keyboard selection, safe renaming, + folded-session archives, deletion, stale file reads and page reload. +- Desktop screenshots were inspected; session controls remain accessible at a + 390 px viewport without document overflow. +- `node scripts/wasm/smoke-protocol.mjs` passed native-reference checks and + byte-identical archive replay. Browser checks reused the parent branch’s + compiled Emscripten 4.0.23 / ViennaRNA 2.7.2 assets with their original metadata; + this follow-up changes no C++ code or worker protocol. +- A fresh GCC 14 release build passed `make tests -j2`: all nine native API/CLI + groups passed. Tracked fixtures were linked into the out-of-tree build’s + `tests/data` directory for the existing CLI harness. +- Static staging (including the new session module and engine digest checks), + shell syntax and `git diff --check` passed. + +## PR #299 review validation (2026-10-24) + +- `npm test --prefix gui`: all 20 tests passed, including FASTA limits, header + warnings, complete column grouping and explicit browser-default JSON replay. +- `scripts/wasm/test-browser.py` passed with real WASM in Chromium and Firefox. + It covers the reordered panels, aligned controls, shared accessibility flags, + seed/accessibility/helix modes, inactive values, explicit-seed Load/Store, + group selection, FASTA scrolling/warnings and result header/name selection, + plus the existing prediction/evaluation, ZIP, abort and session checks. +- Desktop and 390 px mobile screenshots were inspected. All seven panels have + no document overflow; table contents retain their own horizontal scrolling. +- `node scripts/wasm/smoke-protocol.mjs ` passed native-reference + checks and byte-identical JSON archive replay. The unchanged engine uses the + parent branch's Emscripten 4.0.23 / ViennaRNA 2.7.2 assets and original metadata. +- A fresh GCC 14 release build passed `make tests -j2`: all nine API/CLI groups. + Existing CLI fixtures were linked into the out-of-tree `tests/data` directory. +- Static staging with engine digest validation, JavaScript/Python syntax checks, + shell syntax and `git diff --check` passed. diff --git a/gui/docs/step3-frontend-ui.md b/gui/docs/step3-frontend-ui.md index 7f8f8106..e0436b75 100644 --- a/gui/docs/step3-frontend-ui.md +++ b/gui/docs/step3-frontend-ui.md @@ -21,25 +21,26 @@ the group website. Footer and table notes use the available width, wrapping only when the viewport requires it. ```text -Group logo IntaRNA / RNA–RNA interaction analysis University logo +Group logo IntaRNA package / RNA–RNA interaction analysis University logo RNA–RNA interactions Computations stay on your device -Task: Prediction / Evaluation Load example -[ Sequence input | Query | Target | Seed | Helix | SHAPE | Energy | Output | General ] +Task: Prediction / Evaluation +[ Sequences | Seed | Output | Model & Energy | Query | Target | CLI-only ] [ Active panel: e.g. query + target, start indices, evaluation structures ] -Reset parameters Compute -Interactions / search / show-hide columns / CSV + ZIP download +Compute Input Parameters: Examples / Store / Load / Reset +Results / Show-Hide / name / ZIP download / delete +Interactions / search / show-hide columns / CSV download Selected interaction / embedded vaRRI Additional output files / diagnostics / compiled versions ``` -Every CLI group has a tab with one visible panel. Sequence input is selected +Every CLI group has a tab with one visible panel. Sequences is selected initially. Tabs expose `aria-selected`, `aria-controls` and roving focus; Left/Right move between available tabs, and Home/End select the first/last. Tab names wrap on narrow screens. Prediction-only tabs and controls are disabled/hidden during -evaluation; if the selected tab becomes unavailable, Sequence input is selected. -Changing task resets parameters to their CLI defaults while preserving sequence text; Load example also restores the -complementary pair. The native default output count is one; browser users can -request 0–100 interactions per pair. Zero enables accessibility-only output. +evaluation; if the selected tab becomes unavailable, Sequences is selected. +Changing task resets parameters to their task defaults while preserving sequence text; Load example also restores the +complementary pair. The browser defaults to 10 interactions per pair (the native default remains +one), and accepts 0–100. Zero enables accessibility-only output. ## Inputs, defaults and validation @@ -53,15 +54,29 @@ CSV uses a fixed semicolon delimiter. Process settings (threads, file/log paths, parameter files, help/version commands) are replaced by the worker lifecycle, local data inputs, downloads and displayed compiled versions. The base-pair maximization energy model is intentionally -excluded. The helix-block interaction model B is supported. +excluded. The helix-block interaction model B is supported, with helix controls at the end +of Model & Energy, enabled only for model B. Seed constraint offers Disable, +Explicit seed and Identify seeds, enabling only the relevant controls. Query and +Target include their own accessibility/SHAPE columns and synchronized copies of +the two shared accessibility flags. Ignore disables accessibility controls; +import modes disable window/span and enable the data input. Disabled settings +remain editable when re-enabled but do not enter the request. Explicit row grids +align inputs even when their labels wrap. -Prediction accepts plain sequence or up to 20 FASTA records per strand. +CSV columns are grouped as Energy, Output, Sequence, Interaction and Partition +Function, with per-group selection actions and name-only labels with description +tooltips. Prediction adds ED1, ED2, seedE, seedStart1 and seedStart2 to the native +CSV columns; evaluation selects all energy outputs. JSON records these browser +defaults explicitly so native replay retains the same output. + +Prediction accepts plain sequence or up to 50 FASTA records per strand. Evaluation requires one query and target and 1–100 predefined structures. Wrapped/lowercase sequence text and DNA T normalize to uppercase RNA U. A/C/G/U/N -are accepted. Each record is 2–2,000 nt; seed predictions additionally require +are accepted. Each record is 2–5,000 nt; seed predictions additionally require at least `seedBP` nucleotides. FASTA IDs end at the first whitespace, matching the CLI. Duplicate IDs (including collisions after -native CSV semicolon replacement) are rejected. The total selected all-vs-all -query length × target length, or sum of pairwise products, is capped at 1,000,000. +native CSV semicolon replacement) are rejected. Ignored header descriptions are reported below the input. The longest selected +query length × longest selected target length is capped at 1,000,000, including +pairwise mode; the cap is not summed over records. The per-strand start index applies to all records and defaults to one. Negative indices skip zero, matching `RnaSequence`; output coordinates remain inclusive. @@ -78,7 +93,7 @@ accessibility text can be pasted or loaded from a local file (up to 1 MB). Data are written to fixed MEMFS paths, never used as user-provided CLI arguments. Native diagnostics remain available for model-specific failures. -Inputs/results clear on edits; the form disables during execution. Abort and -Escape terminate the worker during startup or C++ execution. Status, errors, +Edits update the form while completed sessions retain their inputs and results; +the form disables during execution. Abort and Escape terminate the worker during startup or C++ execution. Status, errors, empty output and diagnostic text have dedicated regions. Keyboard focus, reduced motion and mobile overflow are covered by real browser tests. diff --git a/gui/docs/step4-results-and-deployment.md b/gui/docs/step4-results-and-deployment.md index c216f88f..0d665211 100644 --- a/gui/docs/step4-results-and-deployment.md +++ b/gui/docs/step4-results-and-deployment.md @@ -30,8 +30,10 @@ Empty output is successful and still permits CSV/accessibility downloads. ## Selected interaction in vaRRI Each row has a keyboard-accessible View button; clicking a row also selects it. -Selection survives table sorting, filtering and visibility changes. Input edits, -new runs, errors and cancellation clear the viewer and archive snapshot. +Selection survives table sorting, filtering and visibility changes within each +session. Input edits, new runs, errors and cancellation preserve prior sessions +and their archive snapshots. Folding a session unloads its viewer; reopening +restores its selected interaction. `visualization.js` uses native seq1 = target, seq2 = query and maps the native CSV identifiers to the completed run's normalized FASTA records (including semicolon-to-underscore ID conversion). It pads the interaction dot-bracket @@ -54,10 +56,10 @@ the full-viewer link permits retry, and scientific outputs remain downloadable. ## Reproducible ZIP download -Download ZIP sits beside Download CSV. `archive.js` creates a standard UTF-8, -uncompressed ZIP with CRC-32 checksums locally, without a library or CDN. It uses +Download ZIP sits in each session header, including while its output is folded. +`archive.js` creates a standard UTF-8, uncompressed ZIP with CRC-32 checksums locally, without a library or CDN. It uses the completed request and result, independent of table sorting/filtering and -column visibility. Editing inputs discards the completed snapshot. +column visibility. Editing inputs preserves every completed snapshot. The archive contains only `parameter.json`, `intarna-interactions.csv`, and every generated additional output with its original filename and contents. JSON embeds @@ -68,7 +70,9 @@ invocation, matching the worker. All JSON is valid with standard parsers. Load/Store use this same JSON format. Store snapshots the current editable form; ZIP snapshots the completed run and ignores table filtering/sorting/visibility. -Both require the compiled version file. See [the JSON workflow](json-workflow.md). +Store requires the compiled version file. Completed sessions still retain their +results if that file is unavailable, recording the missing version information +explicitly in JSON. See [the JSON workflow](json-workflow.md). ## Packaging and deployment diff --git a/gui/examples/evaluation.json b/gui/examples/evaluation.json index b8994f18..84cab246 100644 --- a/gui/examples/evaluation.json +++ b/gui/examples/evaluation.json @@ -5,9 +5,14 @@ "schema": "IntaRNA-parameters-v1", "parameters": { "personality": "IntaRNAeval", - "query": {"text": ">query\nGGGAGGAGGG\n"}, - "target": {"text": ">target\nCCCUCCUCCC\n"}, - "rri": "1||||||||||&1||||||||||" + "query": { + "text": ">query\nGGGAGGAGGG\n" + }, + "target": { + "text": ">target\nCCCUCCUCCC\n" + }, + "rri": "1||||||||||&1||||||||||", + "outCsvCols": "id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E,Etotal,ED1,ED2,E_init,E_loops,E_dangleL,E_dangleR,E_endL,E_endR,E_hybrid,E_norm,E_hybridNorm,E_add,Eall,Eall1,Eall2,EallTotal,seedE,seedED1,seedED2" } } } diff --git a/gui/examples/prediction.json b/gui/examples/prediction.json index 2b834042..bd76029a 100644 --- a/gui/examples/prediction.json +++ b/gui/examples/prediction.json @@ -4,8 +4,14 @@ "configuration": { "schema": "IntaRNA-parameters-v1", "parameters": { - "query": {"text": ">query\nGGGAGGAGGG\n"}, - "target": {"text": ">target\nCCCUCCUCCC\n"} + "query": { + "text": ">query\nGGGAGGAGGG\n" + }, + "target": { + "text": ">target\nCCCUCCUCCC\n" + }, + "outNumber": 10, + "outCsvCols": "id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E,ED1,ED2,seedE,seedStart1,seedStart2" } } } diff --git a/gui/index.html b/gui/index.html index c4727dca..a77b74e7 100644 --- a/gui/index.html +++ b/gui/index.html @@ -14,7 +14,7 @@
@@ -38,40 +38,56 @@
5′ to 3′
or drop in the field
- -
No sequence entered
+ +
No sequence entered
5′ to 3′
or drop in the field
- -
No sequence entered
+ +
No sequence entered
-

Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 20 records per field and 2–2,000 nt per record; total query × target length across selected pairs ≤ 1,000,000. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.

+

Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 50 records per field and 2–5,000 nt per record; longest selected query × longest selected target ≤ 1,000,000. FASTA names stop at the first whitespace. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.

-
Example

Loading examples…

+
Input Parameters:
Examples

Loading examples…

-

Interactions

Enter your sequences or choose an example, then compute.

- - +

Results

Enter your sequences or choose an example, then compute.

+

Completed runs stay in this tab until deleted or the page is reloaded. Select a session to restore its inputs; download its ZIP to keep a copy.

+
+
diff --git a/gui/options.js b/gui/options.js index efd310b0..4e95bccf 100644 --- a/gui/options.js +++ b/gui/options.js @@ -20,62 +20,83 @@ const strand = (prefix, label) => [ number(`${prefix}RegionLenMax`, 'Automatic region length limit', 0, 0, 99999, { ...prediction, hint: '0 disables automatic region detection.' }), number(`${prefix}PfScale`, `${label} partition-function scale`, 1.07, 1, 99999, { step: 'any' }), ]; -export const GROUPS = [ - { key: 'query-options', label: 'Query', fields: strand('q', 'Query') }, - { key: 'target-options', label: 'Target', fields: strand('t', 'Target') }, - { key: 'seed-options', label: 'Seed', prediction: true, fields: [ - flag('noSeed', 'Do not require a seed', prediction), - text('seedTQ', 'Explicit seeds', { ...prediction, hint: 'Target&query dot-bar encodings, e.g. 1|||&1|||; separate seeds with commas.' }), - number('seedBP', 'Seed base pairs', 7, 2, 20, prediction), - number('seedMaxUP', 'Maximum unpaired seed bases (both strands)', 0, 0, 20, prediction), - number('seedQMaxUP', 'Maximum unpaired query seed bases', -1, -1, 20, { ...prediction, hint: '-1 uses the combined limit.' }), - number('seedTMaxUP', 'Maximum unpaired target seed bases', -1, -1, 20, prediction), - number('seedMaxE', 'Maximum seed energy (kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), - number('seedMaxEhybrid', 'Maximum seed hybridization energy (kcal/mol)', 999, -999, 999, { ...prediction, step: 'any' }), - number('seedMinPu', 'Minimum seed unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), - text('seedQRange', 'Query seed regions', prediction), text('seedTRange', 'Target seed regions', prediction), - flag('seedNoGU', 'Exclude GU pairs in seeds', prediction), flag('seedNoGUend', 'Exclude GU pairs at seed ends', prediction), - ] }, - { key: 'helix-options', label: 'Helix (model B)', prediction: true, fields: [ - number('helixMinBP', 'Minimum helix base pairs', 2, 2, 4, prediction), - number('helixMaxBP', 'Maximum helix base pairs', 10, 2, 20, prediction), - number('helixMaxIL', 'Maximum helix interior-loop size', 0, 0, 2, prediction), - number('helixMinPu', 'Minimum helix unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), - number('helixMaxE', 'Maximum helix energy (kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), - flag('helixFullE', 'Use full helix energy for the energy limit', prediction), - ] }, - { key: 'shape-options', label: 'SHAPE', fields: ['q', 't'].flatMap((p) => [ +const fields = new Map([ + ...strand('q', 'Query'), ...strand('t', 'Target'), + flag('accNoLP', 'Exclude lonely pairs in accessibility'), + flag('accNoGUend', 'Exclude GU helix ends in accessibility'), + flag('noSeed', 'Do not require a seed', prediction), + text('seedTQ', 'Explicit seeds', { ...prediction, hint: 'Target&query dot-bar encodings, e.g. 1|||&1|||; separate seeds with commas.' }), + number('seedBP', 'Seed base pairs', 7, 2, 20, prediction), + number('seedMaxUP', 'Maximum unpaired seed bases (both strands)', 0, 0, 20, prediction), + number('seedQMaxUP', 'Maximum unpaired query seed bases', -1, -1, 20, { ...prediction, hint: '-1 uses the combined limit.' }), + number('seedTMaxUP', 'Maximum unpaired target seed bases', -1, -1, 20, prediction), + number('seedMaxE', 'Maximum seed energy (kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), + number('seedMaxEhybrid', 'Maximum seed hybridization energy (kcal/mol)', 999, -999, 999, { ...prediction, step: 'any' }), + number('seedMinPu', 'Minimum seed unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), + text('seedQRange', 'Query seed regions', prediction), text('seedTRange', 'Target seed regions', prediction), + flag('seedNoGU', 'Exclude GU pairs in seeds', prediction), flag('seedNoGUend', 'Exclude GU pairs at seed ends', prediction), + number('helixMinBP', 'Minimum helix base pairs', 2, 2, 4, prediction), + number('helixMaxBP', 'Maximum helix base pairs', 10, 2, 20, prediction), + number('helixMaxIL', 'Maximum helix interior-loop size', 0, 0, 2, prediction), + number('helixMinPu', 'Minimum helix unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), + number('helixMaxE', 'Maximum helix energy (kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), + flag('helixFullE', 'Use full helix energy for the energy limit', prediction), + select('model', 'Interaction model', 'X', { X: 'Seed extension (MFE)', S: 'Single site (MFE)', P: 'Ensemble energy', B: 'Helix blocks (MFE)' }, prediction), + select('mode', 'Prediction algorithm', 'H', { H: 'Heuristic', M: 'Exact', S: 'Seed only', K: 'Greedy seed extension' }, prediction), + select('kineticScore', 'Greedy extension score', 'A', { A: 'Energy change', B: 'Change / (1 + s1 + s2)', C: 'Change / (1 + 2 max(s1,s2))' }, prediction), + select('energyVRNA', 'ViennaRNA parameter set', 'Turner04', { Turner04: 'Turner 2004', Turner99: 'Turner 1999', Andronescu07: 'Andronescu 2007' }), + number('temperature', 'Temperature (°C)', 37, 0, 100, { step: 'any' }), + number('energyAdd', 'Additional energy (kcal/mol)', 0, -999, 999, { step: 'any' }), + flag('energyNoDangles', 'Exclude dangling-end energy'), + number('windowWidth', 'Prediction window width', 0, 0, 99999, { ...prediction, hint: '0 disables prediction windows; otherwise at least 10 nt.' }), + number('windowOverlap', 'Prediction window overlap', 150, 0, 99999, prediction), + number('outNumber', 'Maximum interactions per sequence pair', 1, 0, 100, { ...prediction, hint: 'Browser limit: 100; 0 computes accessibility only.' }), + select('outOverlap', 'Allow overlapping interactions', 'B', { B: 'Both strands', N: 'Neither strand', T: 'Target only', Q: 'Query only' }, prediction), + number('outMaxE', 'Maximum interaction energy (exclusive, kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), + number('outDeltaE', 'Maximum difference from MFE (kcal/mol)', 100, 0, 100, { ...prediction, step: 'any' }), + number('outMinPu', 'Minimum unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), + flag('outBestSeedOnly', 'Report only the best seed', prediction), + flag('outNoLP', 'Exclude lonely interaction pairs', prediction), flag('outNoGUend', 'Exclude GU interaction helix ends', prediction), + flag('outPerRegion', 'Report each region combination separately', prediction), flag('outPairwise', 'Match FASTA records pairwise', prediction), + select('outCsvSort', 'CSV download sort column', '', { '': 'Native computation order', ...Object.fromEntries(CSV_CATALOG.map((column) => [column.key, column.label])) }), + text('outSpotProbSpots', 'Interaction probability spots', { ...prediction, hint: 'Optional target&query coordinate pairs, e.g. 3&7,5&9. Empty selects all spots.' }), + ...['q', 't'].flatMap((p) => [ text(`${p}Shape`, `${p === 'q' ? 'Query' : 'Target'} SHAPE data`, { type: 'textarea', hint: 'One sequence only. Paste position and reactivity, one pair per line; positions start at 1.' }), { ...text(`${p}ShapeMethod`, 'SHAPE method'), value: 'D', hint: 'D, Dm1.8b-0.6, Z, Zb0.89 or W.' }, { ...text(`${p}ShapeConversion`, 'SHAPE conversion'), value: 'Os1.6i-2.29', hint: 'M, C0.25, S, Ls0.68i0.2 or Os1.6i-2.29.' }, + ]), +].map((field) => [field.key, field])); + +// Explicit rows separate visual order from native parameter defaults. +const rows = (keys) => keys.map((row) => row.map((key) => fields.get(key))); +const strandRows = (p) => rows([ + [`${p}Set`, `${p}Region`, `${p}IntLenMax`, `${p}IntLoopMax`, `${p}RegionLenMax`], + [`${p}Acc`, `${p}AccW`, `${p}AccL`, `${p}PfScale`], +]); +const strandGroup = (p, label) => ({ + key: `${label.toLowerCase()}-options`, label, strand: p, + rows: strandRows(p), + fields: [...strandRows(p).flat(), ...rows([ + ...(p === 'q' ? [['accNoLP', 'accNoGUend']] : []), + [`${p}AccConstr`, `${p}AccFile`, `${p}Shape`, `${p}ShapeMethod`, `${p}ShapeConversion`], + ]).flat()], +}); +export const GROUPS = [ + { key: 'seed-options', label: 'Seed', prediction: true, rows: rows([ + ['noSeed'], ['seedTQ'], ['seedBP', 'seedMaxUP', 'seedQMaxUP', 'seedTMaxUP'], + ['seedMaxE', 'seedMinPu', 'seedMaxEhybrid'], ['seedQRange', 'seedTRange'], ['seedNoGU', 'seedNoGUend'], ]) }, - { key: 'interaction-options', label: 'Interaction and energy', fields: [ - select('model', 'Interaction model', 'X', { X: 'Seed extension (MFE)', S: 'Single site (MFE)', P: 'Ensemble energy', B: 'Helix blocks (MFE)' }, prediction), - select('mode', 'Prediction algorithm', 'H', { H: 'Heuristic', M: 'Exact', S: 'Seed only', K: 'Greedy seed extension' }, prediction), - select('kineticScore', 'Greedy extension score', 'A', { A: 'Energy change', B: 'Change / (1 + s1 + s2)', C: 'Change / (1 + 2 max(s1,s2))' }, prediction), - select('energyVRNA', 'ViennaRNA parameter set', 'Turner04', { Turner04: 'Turner 2004', Turner99: 'Turner 1999', Andronescu07: 'Andronescu 2007' }), - number('temperature', 'Temperature (°C)', 37, 0, 100, { step: 'any' }), - number('energyAdd', 'Additional energy (kcal/mol)', 0, -999, 999, { step: 'any' }), - flag('energyNoDangles', 'Exclude dangling-end energy'), flag('accNoLP', 'Exclude lonely pairs in accessibility'), - flag('accNoGUend', 'Exclude GU helix ends in accessibility'), - number('windowWidth', 'Prediction window width', 0, 0, 99999, { ...prediction, hint: '0 disables prediction windows; otherwise at least 10 nt.' }), - number('windowOverlap', 'Prediction window overlap', 150, 0, 99999, prediction), - ] }, - { key: 'output-options', label: 'Output', fields: [ - number('outNumber', 'Maximum interactions per sequence pair', 1, 0, 100, { ...prediction, hint: 'Browser limit: 100; 0 computes accessibility only.' }), - select('outOverlap', 'Allow overlapping interactions', 'B', { B: 'Both strands', N: 'Neither strand', T: 'Target only', Q: 'Query only' }, prediction), - number('outMaxE', 'Maximum interaction energy (exclusive, kcal/mol)', 0, -999, 999, { ...prediction, step: 'any' }), - number('outDeltaE', 'Maximum difference from MFE (kcal/mol)', 100, 0, 100, { ...prediction, step: 'any' }), - number('outMinPu', 'Minimum unpaired probability', 0, 0, 1, { ...prediction, step: 'any' }), - flag('outBestSeedOnly', 'Report only the best seed', prediction), - flag('outNoLP', 'Exclude lonely interaction pairs', prediction), flag('outNoGUend', 'Exclude GU interaction helix ends', prediction), - flag('outPerRegion', 'Report each region combination separately', prediction), flag('outPairwise', 'Match FASTA records pairwise', prediction), - select('outCsvSort', 'CSV download sort column', '', { '': 'Native computation order', ...Object.fromEntries(CSV_CATALOG.map((column) => [column.key, column.label])) }), - text('outSpotProbSpots', 'Interaction probability spots', { ...prediction, hint: 'Optional target&query coordinate pairs, e.g. 3&7,5&9. Empty selects all spots.' }), - ] }, -]; + { key: 'output-options', label: 'Output', fields: [...fields.values()].filter((field) => field.key.startsWith('out')) }, + { key: 'interaction-options', label: 'Model & Energy', rows: rows([ + ['model', 'mode'], ['energyVRNA', 'temperature', 'energyAdd'], ['energyNoDangles'], + ['windowWidth', 'windowOverlap'], ['kineticScore'], + ['helixMinBP', 'helixMaxBP', 'helixMaxIL', 'helixMinPu', 'helixMaxE'], ['helixFullE'], + ]) }, + strandGroup('q', 'Query'), strandGroup('t', 'Target'), +].map((group) => ({ ...group, fields: group.fields ?? group.rows.flat() })); export const FIELDS = GROUPS.flatMap((group) => group.fields); export const PARAMETER_DEFAULTS = Object.freeze(Object.fromEntries(FIELDS.map((field) => [field.key, field.value]))); +export const UI_DEFAULTS = Object.freeze({ ...PARAMETER_DEFAULTS, outNumber: 10 }); export const EXTRA_OUTPUTS = [ ['qMinE', 'Query minimum energies', true], ['tMinE', 'Target minimum energies', true], ['qAcc', 'Query opening energies'], ['tAcc', 'Target opening energies'], diff --git a/gui/parameter-file.js b/gui/parameter-file.js index a076ee1e..7e52f341 100644 --- a/gui/parameter-file.js +++ b/gui/parameter-file.js @@ -1,4 +1,4 @@ -import { JSON_SCHEMA, CSV_COLUMNS, normalizeParameters } from './protocol.js'; +import { JSON_SCHEMA, CSV_COLUMNS, LIMITS, normalizeParameters } from './protocol.js'; import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS } from './options.js'; import { CSV_CATALOG } from './columns.js'; @@ -25,8 +25,8 @@ export function exportParameters(request, provenance) { const normalized = normalizeParameters(request); const { columns, outputs } = selections(request); const parameters = { - query: { text: text(typeof request.query === 'string' ? request.query : request.query.fasta, 'Query', 60000) }, - target: { text: text(typeof request.target === 'string' ? request.target : request.target.fasta, 'Target', 60000) }, + query: { text: text(typeof request.query === 'string' ? request.query : request.query.fasta, 'Query', LIMITS.maxFastaBytes) }, + target: { text: text(typeof request.target === 'string' ? request.target : request.target.fasta, 'Target', LIMITS.maxFastaBytes) }, }; if (request.task === 'evaluation') parameters.personality = 'IntaRNAeval'; for (const [key, value] of Object.entries(normalized)) { @@ -73,7 +73,7 @@ export function importParameters(source) { // Plain nucleotide strings are native inline sequences; file paths need // explicit embedding because the browser cannot open local paths. if (typeof content !== 'string' || (content === value && (embeddedKeys.includes(key) || !/^[ACGUTNacgutn]*$/.test(value)))) throw new Error(`${key}: embed the input using {"text":"file contents"}.`); - if (key === 'query' || key === 'target') request[key] = text(content, key, 60000); + if (key === 'query' || key === 'target') request[key] = text(content, key, LIMITS.maxFastaBytes); else request.parameters[key] = text(content, key); } else if (key === 'personality') { if (!['IntaRNA', 'IntaRNAeval'].includes(value)) throw new Error('The browser supports IntaRNA prediction and IntaRNAeval evaluation.'); diff --git a/gui/protocol.js b/gui/protocol.js index 92f078f8..d452de28 100644 --- a/gui/protocol.js +++ b/gui/protocol.js @@ -1,41 +1,41 @@ import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS, ENSEMBLE_COLUMNS } from './options.js'; import { CSV_CATALOG } from './columns.js'; -// Bound total work as well as per-strand length, including all-vs-all FASTA jobs. -export const LIMITS = Object.freeze({ minLength: 2, maxLength: 2000, maxRecords: 20, maxPairs: 1000000 }); +// Bound the largest pair's matrix while allowing batches of shorter sequences. +export const LIMITS = Object.freeze({ minLength: 2, maxLength: 5000, maxRecords: 50, maxPairs: 1000000, maxFastaBytes: 1000000 }); export const CSV_COLUMNS = 'id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E'; export const CONTEXT_COLUMNS = ['id1', 'start1', 'end1', 'id2', 'start2', 'end2', 'E']; export const DEFAULTS = Object.freeze({ query: '>query\nGGGAGGAGGG', target: '>target\nCCCUCCUCCC', task: 'prediction' }); export const EVALUATION_EXAMPLE = '1||||||||||&1||||||||||'; export function parseFasta(text, label, multiple = true) { - if (typeof text !== 'string' || text.length > 60000 || !text.trim()) throw new Error(`${label}: enter a sequence or FASTA records.`); + if (typeof text !== 'string' || text.length > LIMITS.maxFastaBytes || !text.trim()) throw new Error(`${label}: enter a sequence or FASTA records (at most 1 MB).`); const lines = text.trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const records = []; + const records = [], warnings = []; let current; for (const line of lines) { if (line.startsWith('>')) { - current = { id: line.slice(1).trim(), sequence: '' }; + const header = line.slice(1).trim(); + current = { id: header.split(/\s+/)[0], sequence: '' }; if (!current.id || current.id.length > 200 || /[\x00-\x1f\x7f]/.test(current.id)) throw new Error(`${label}: the FASTA header needs a name (at most 200 characters).`); - // The native FASTA parser takes the identifier before the first space. - current.id = current.id.split(/\s+/)[0]; + if (/\s+\S/.test(header)) warnings.push(`${label}: ignoring FASTA description after the name “${current.id}”.`); records.push(current); } else { if (!current) { current = { id: label.toLowerCase(), sequence: '' }; records.push(current); } current.sequence += line.replace(/\s/g, '').toUpperCase().replaceAll('T', 'U'); } } - if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 20 FASTA records' : 'evaluation requires exactly one FASTA record'}.`); + if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 50 FASTA records' : 'evaluation requires exactly one FASTA record'}.`); const ids = new Set(); for (const record of records) { if (!record.sequence || /[^ACGUN]/.test(record.sequence)) throw new Error(`${label}: use only A, C, G, U, T or N in every sequence.`); - if (record.sequence.length < LIMITS.minLength || record.sequence.length > LIMITS.maxLength) throw new Error(`${label}: use 2–2,000 nucleotides per record.`); + if (record.sequence.length < LIMITS.minLength || record.sequence.length > LIMITS.maxLength) throw new Error(`${label}: use 2–5,000 nucleotides per record.`); // Native CSV replaces semicolons with underscores. Keep pair grouping unique. const id = record.id.replaceAll(';', '_'); if (ids.has(id)) throw new Error(`${label}: FASTA identifiers must be unique (also after replacing semicolons with underscores).`); ids.add(id); } - return { records, fasta: records.map(({ id, sequence }) => `>${id}\n${sequence}\n`).join('') }; + return { records, warnings, fasta: records.map(({ id, sequence }) => `>${id}\n${sequence}\n`).join('') }; } export function parseSequence(text, label) { const parsed = parseFasta(text, label, false); @@ -148,8 +148,8 @@ export function prepareRequest(request) { const parameters = normalizeParameters(request); const qRecords = selectedRecords(query, parameters.qSet, 'qSet'), tRecords = selectedRecords(target, parameters.tSet, 'tSet'); if (parameters.outPairwise && qRecords.length !== tRecords.length) throw new Error('Pairwise output requires the same number of selected query and target records.'); - const work = parameters.outPairwise ? qRecords.reduce((sum, q, i) => sum + q.sequence.length * tRecords[i].sequence.length, 0) : qRecords.reduce((sum, q) => sum + q.sequence.length, 0) * tRecords.reduce((sum, t) => sum + t.sequence.length, 0); - if (work > LIMITS.maxPairs) throw new Error('The total query × target length across selected sequence pairs must be at most 1,000,000.'); + const work = Math.max(...qRecords.map((q) => q.sequence.length)) * Math.max(...tRecords.map((t) => t.sequence.length)); + if (work > LIMITS.maxPairs) throw new Error('The longest selected query × longest selected target must be at most 1,000,000.'); // Validate sequence-dependent settings before loading the engine. for (const [p, records] of [['q', qRecords], ['t', tRecords]]) { diff --git a/gui/results.js b/gui/results.js index aeceb38a..92a06f70 100644 --- a/gui/results.js +++ b/gui/results.js @@ -66,15 +66,15 @@ export class ResultsView { constructor(root) { this.root = root; this.rows = []; this.key = 'E'; this.direction = 1; this.visualization = new InteractionVisualization(root); - this.search = root.querySelector('#search'); + this.search = root.querySelector('[data-role=search]'); this.table = root.querySelector('table'); this.body = this.table.querySelector('tbody'); this.header = this.table.querySelector('thead tr'); - this.count = root.querySelector('#visible-count'); - this.empty = root.querySelector('#no-matches'); - this.picker = root.querySelector('#visible-columns'); + this.count = root.querySelector('[data-role=visible-count]'); + this.empty = root.querySelector('[data-role=no-matches]'); + this.picker = root.querySelector('[data-role=visible-columns]'); this.search.addEventListener('input', () => this.render()); - root.querySelector('#download').addEventListener('click', () => downloadText('intarna-interactions.csv', this.csv)); + root.querySelector('[data-role=download]').addEventListener('click', () => downloadText('intarna-interactions.csv', this.csv)); } clear() { this.rows = []; this.csv = ''; this.body.replaceChildren(); this.root.hidden = true; @@ -103,11 +103,15 @@ export class ResultsView { const rows = selectRows(this.rows, this.search.value, this.key, this.direction); const columns = this.columns.filter((column) => this.visible.has(column.key)); this.header.replaceChildren(); - const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.textContent = 'vaRRI'; + const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.setAttribute('aria-label', 'Interaction visualization'); this.header.append(viewHeading); for (const column of columns) { const cell = document.createElement('th'), button = document.createElement('button'); - cell.scope = 'col'; button.type = 'button'; button.textContent = column.label; + cell.scope = 'col'; button.type = 'button'; + const split = column.numeric ? column.label.indexOf(' ') : -1; + if (split > 0) button.append(document.createTextNode(column.label.slice(0, split)), document.createElement('br'), document.createTextNode(column.label.slice(split + 1))); + else button.textContent = column.label; + button.title = column.description ?? column.label; const active = column.key === this.key, direction = this.direction === 1 ? 'ascending' : 'descending'; cell.setAttribute('aria-sort', active ? direction : 'none'); button.setAttribute('aria-label', `Sort by ${column.label}${active ? `, currently ${direction}` : ''}`); diff --git a/gui/sessions.js b/gui/sessions.js new file mode 100644 index 00000000..71b712d0 --- /dev/null +++ b/gui/sessions.js @@ -0,0 +1,124 @@ +import { ResultsView, downloadBlob } from './results.js'; +import { serializeParameters, importParameters } from './parameter-file.js'; +import { zipFiles } from './archive.js'; + +// Sessions live only in this page. Each owns a JSON snapshot and its own view; +// editing the form or exploring another session cannot change an earlier run. +export class SessionsView { + constructor(root, template, restore, reportError, announce) { + this.root = root; this.template = template; this.restore = restore; this.reportError = reportError; this.announce = announce; + this.entries = new Map(); this.nextNumber = 1; this.selected = null; + } + + deselect() { + this.selected = null; + for (const session of this.entries.values()) { + session.root.dataset.selected = 'false'; + session.toggle.textContent = 'Show'; + } + } + + setExpanded(session, expanded) { + session.panel.hidden = !expanded; + session.toggle.setAttribute('aria-expanded', String(expanded)); + session.toggle.textContent = expanded ? 'Hide' : 'Show'; + // Unload third-party viewers while folded. The table selection is retained + // and can be visualized again without recomputing the prediction. + if (!expanded) session.view.visualization.clear(); + else if (session.view.selected) session.view.visualization.show(session.view.selected, session.view.request); + } + + select(session, restore = true) { + if (restore) this.restore(importParameters(session.parameterJson), session.name); + this.selected = session.id; + for (const entry of this.entries.values()) { + entry.root.dataset.selected = String(entry === session); + this.setExpanded(entry, entry === session); + } + } + + add(request, result, provenance) { + // Prepared indices live in parameters, whereas the JSON serializer accepts + // editable requests with top-level indices. Preserve signed coordinates. + const parameterJson = serializeParameters({ ...request, qIdxPos0: request.parameters.qIdxPos0, tIdxPos0: request.parameters.tIdxPos0 }, provenance); + const root = this.template.content.firstElementChild.cloneNode(true); + const find = (role) => root.querySelector(`[data-role=${role}]`); + const id = `session-${this.nextNumber}`, task = request.task === 'evaluation' ? 'Evaluation' : 'Prediction'; + const session = { + id, name: `${task} ${this.nextNumber}`, parameterJson, root, + panel: find('panel'), toggle: find('toggle'), view: new ResultsView(find('results-view')), + }; + const count = session.view.show(result.csv, result.csvColumns, request); + const summary = count ? `${task} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && request.parameters.outNumber === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; + find('summary').textContent = summary; + root.dataset.sessionId = id; + session.panel.id = `${id}-panel`; session.toggle.setAttribute('aria-controls', session.panel.id); + const name = find('name'); name.value = session.name; + const rename = () => { + session.name = name.value.trim() || `${task} ${id.slice('session-'.length)}`; + root.setAttribute('aria-label', session.name); + session.toggle.setAttribute('aria-label', `Show or hide results and restore inputs for ${session.name}`); + find('delete').setAttribute('aria-label', `Delete session ${session.name}`); + find('download-zip').setAttribute('aria-label', `Download ZIP for ${session.name}`); + }; + name.addEventListener('input', rename); + name.addEventListener('change', () => { rename(); name.value = session.name; }); + name.addEventListener('keydown', (event) => { if (event.key === 'Enter') name.blur(); }); + rename(); + // The name and header background also select a run. Action buttons retain + // their own behavior, so exporting/deleting cannot overwrite edited inputs. + const header = root.querySelector('.session-header'); + header.tabIndex = 0; header.setAttribute('role', 'group'); + header.setAttribute('aria-label', 'Select result and restore its inputs'); + for (const event of ['click', 'focusin']) header.addEventListener(event, (event) => { + if (!event.target.closest('button') && (this.selected !== id || session.panel.hidden)) this.select(session); + }); + header.addEventListener('keydown', (event) => { + if (event.target === header && ['Enter', ' '].includes(event.key)) { event.preventDefault(); this.select(session); } + }); + // Clicking or keyboard-focusing an output area selects its computed inputs. + for (const event of ['click', 'focusin']) session.panel.addEventListener(event, () => { + if (this.selected !== id) this.select(session); + }); + session.toggle.addEventListener('click', () => { + if (this.selected === id && !session.panel.hidden) this.setExpanded(session, false); + else this.select(session); + }); + + // Each archive uses the saved JSON and full outputs, never the current form, + // filtered table, or the session selected at download time. + find('download-zip').addEventListener('click', () => { + try { + const files = [{ name: 'parameter.json', text: parameterJson }, { name: 'intarna-interactions.csv', text: session.view.csv }, ...result.files]; + const filename = session.name.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 80) || id; + downloadBlob(`${filename}.zip`, new Blob([zipFiles(files)], { type: 'application/zip' })); + } catch (error) { this.reportError(new Error(`ZIP download failed: ${error.message}`)); } + }); + for (const file of result.files) { + const details = document.createElement('details'), label = document.createElement('summary'); label.textContent = file.name; + const preview = document.createElement('pre'); preview.textContent = file.text.slice(0, 100000); + const download = document.createElement('button'); download.type = 'button'; download.className = 'secondary'; download.textContent = 'Download file'; + download.addEventListener('click', () => downloadBlob(file.name, new Blob([file.text], { type: 'text/plain;charset=utf-8' }))); + details.append(label, download, preview); + if (file.text.length > 100000) { + const note = document.createElement('p'); note.textContent = 'Preview limited to 100,000 characters. Download contains the complete file.'; details.append(note); + } + find('file-list').append(details); + } + find('output-files').hidden = !result.files.length; + find('diagnostic-text').textContent = result.diagnostics; find('diagnostics').hidden = !result.diagnostics; + + find('delete').addEventListener('click', () => { + const entries = [...this.entries.values()], index = entries.indexOf(session); + const next = entries[index + 1] ?? entries[index - 1]; + session.view.clear(); root.remove(); this.entries.delete(id); + // Deleting output does not silently overwrite an edited form. + if (this.selected === id) this.selected = null; + this.announce(`${session.name} deleted. ${this.entries.size} session${this.entries.size === 1 ? '' : 's'} remaining.`); + (next?.toggle ?? document.getElementById('compute')).focus(); + }); + this.entries.set(id, session); this.nextNumber++; + this.root.prepend(root); this.select(session, false); + return summary; + } +} diff --git a/gui/styles.css b/gui/styles.css index 3cb26b9c..360ba4c4 100644 --- a/gui/styles.css +++ b/gui/styles.css @@ -4,6 +4,8 @@ --brand-blue:#145a96; --brand-blue-dark:#0f4676; --brand-blue-soft:#eaf2f8; + --table-header:#d9e8f6; + --table-selection:#c6e5f4; --brand-accent:#f4bb44; --surface:#fff; --surface-muted:#f3f7fb; @@ -282,8 +284,26 @@ fieldset { grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:20px 24px; align-items:start; + margin-bottom:20px; } +/* Explicit rows preserve the requested grouping; subgrid aligns inputs below + labels of different heights without fixing a font-dependent label height. */ +.field-grid.columns-1 { grid-template-columns:minmax(0,1fr); } +.field-grid.columns-2 { grid-template-columns:repeat(2,minmax(0,1fr)); } +.field-grid.columns-3 { grid-template-columns:repeat(3,minmax(0,1fr)); } +.field-grid.columns-4 { grid-template-columns:repeat(4,minmax(0,1fr)); } +.field-grid.columns-5 { grid-template-columns:repeat(5,minmax(0,1fr)); } +.field-grid > .field:not(.check) { display:grid; grid-row:span 4; grid-template-rows:subgrid; row-gap:5px; } +.field-grid > .field:not(.check) > :nth-child(2) { align-self:start; } +.accessibility-columns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin-top:20px; } +.accessibility-columns > div { min-width:0; } +.seed-mode { max-width:24rem; margin-bottom:20px; } +.field-grid:has(#noSeed) { display:none; } +#query,#target { white-space:pre; overflow:auto; } +#sequence-help,#general-options p { max-width:none; } +.input-warning { font-size:.85rem; color:#79510b; max-width:none; overflow-wrap:anywhere; } + .strand-heading,.sequence-meta { display:flex; align-items:center; @@ -354,10 +374,6 @@ fieldset { background:transparent; } -.field:has(textarea) { - grid-column:1/-1; -} - .field:has(:disabled) { opacity:.65; } @@ -396,6 +412,11 @@ fieldset { padding:.3rem .6rem; } +.csv-group { margin-top:16px; } +.csv-group legend { font-weight:600; } +.text-button { background:none; border:0; padding:.15rem .5rem; color:var(--brand-blue); font-size:.8rem; text-decoration:underline; font-weight:400; } +.csv-columns { grid-template-columns:repeat(auto-fill,minmax(125px,1fr)); gap:6px 16px; padding:6px 0; } + .results { background:var(--surface); border:1px solid var(--border); @@ -426,7 +447,7 @@ fieldset { font-size:.9rem; } -#visible-count { +[data-role=visible-count] { font-size:.85rem; color:var(--muted); } @@ -450,14 +471,15 @@ table { } th { - background:var(--brand-blue-soft); + background:var(--table-header); text-align:left; + padding:0 5px; } th button { background:transparent; border:0; - padding:12px; + padding:10px 0; color:var(--brand-blue-dark); width:100%; text-align:left; @@ -473,7 +495,7 @@ th[aria-sort=descending] button::after { td { border-top:1px solid #e0e8f0; - padding:10px 12px; + padding:10px 5px; } tr:nth-child(even) { @@ -491,7 +513,7 @@ td.number { } .view-interaction { - padding:.25rem .6rem; + padding:.25rem .35rem; color:var(--brand-blue-dark); background:var(--surface); } @@ -500,16 +522,17 @@ tbody tr { cursor:pointer; } -tbody tr:hover,tbody tr.selected { +tbody tr:hover { background:var(--brand-blue-soft); } +tbody tr.selected { background:var(--table-selection); } .view-interaction[aria-pressed=true] { background:var(--brand-blue); color:white; } -#visualization { +[data-role=visualization] { margin-top:20px; } @@ -520,7 +543,7 @@ tbody tr:hover,tbody tr.selected { gap:16px; } -#varri-frame { +[data-role=varri-frame] { display:block; width:100%; height:600px; @@ -530,17 +553,17 @@ tbody tr:hover,tbody tr.selected { background:white; } -#visualization-status { +[data-role=visualization-status] { max-width:none; overflow-wrap:anywhere; font-size:.9rem; } -#output-files { +[data-role=output-files] { margin-top:24px; } -#file-list details,#diagnostics { +[data-role=file-list] details,:is(#diagnostics, [data-role=diagnostics]) { border-top:1px solid var(--border); margin-top:12px; padding-top:12px; @@ -555,7 +578,7 @@ pre { padding:12px; } -#diagnostics pre { +:is(#diagnostics, [data-role=diagnostics]) pre { white-space:pre-wrap; overflow-wrap:anywhere; } @@ -674,6 +697,7 @@ dialog::backdrop { .field-grid { grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr)); } + .field-grid[class*=columns-],.accessibility-columns { grid-template-columns:1fr; } .results { padding:14px; } @@ -721,5 +745,20 @@ textarea.drag-over { outline:3px solid var(--brand-accent); background:var(--bra .task-choice:has(:checked) small { color:white; } .task-choice:has(:focus-visible) { outline:3px solid var(--brand-accent); outline-offset:3px; } .compute-bar { justify-content:flex-start; gap:8px; } -#compute { margin-left:auto; } -@media(max-width:480px) { #compute { flex-basis:100%; margin-left:0; } } +#compute { margin-right:24px; } +.parameter-actions-label { font-size:.9rem; } +@media(max-width:480px) { #compute { flex-basis:100%; margin-right:0; } .parameter-actions-label { flex-basis:100%; } } + +/* Every completed run owns its controls and output, even while folded. */ +.session { margin-top:18px; border:1px solid var(--border); border-radius:6px; min-width:0; } +.session[data-selected=true] { border-color:var(--brand-blue); } +.session-header { display:flex; align-items:center; flex-wrap:wrap; gap:10px; padding:14px; background:var(--surface-muted); border-radius:6px; } +.session-name { display:flex; align-items:center; flex:1 1 20rem; gap:10px; font-size:.85rem; } +.session-name input { flex:1; width:12rem; font-weight:600; } +.session-header button { font-size:.85rem; } +.session-header [data-role=toggle]::before { content:'▸ '; } +.session-header [aria-expanded=true]::before { content:'▾ '; } +.delete-session { display:inline-flex; align-items:center; } +.session-panel { padding:16px; } +.session-summary { color:var(--muted); } +@media(max-width:480px) { .session-header,.session-panel { padding:10px; } } diff --git a/gui/tests/protocol.test.mjs b/gui/tests/protocol.test.mjs index 42517703..07a60071 100644 --- a/gui/tests/protocol.test.mjs +++ b/gui/tests/protocol.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { DEFAULTS, EVALUATION_EXAMPLE, parseSequence, parseFasta, prepareRequest, makeArguments, CSV_COLUMNS, sequenceOffset } from '../protocol.js'; import { FIELDS } from '../options.js'; -import { CSV_CATALOG } from '../columns.js'; +import { CSV_CATALOG, CSV_GROUPS, defaultColumns } from '../columns.js'; const request = (parameters = {}, extra = {}) => ({ ...DEFAULTS, parameters, ...extra }); test('FASTA normalization, identifiers, bounded multi-record work and subsets', () => { @@ -12,11 +12,11 @@ test('FASTA normalization, identifiers, bounded multi-record work and subsets', const fasta = '>a\nGGGAGGAGGG\n>b\nGGGAGGAGGG'; assert.equal(prepareRequest(request({}, { query: fasta })).query.records.length, 2); assert.throws(() => prepareRequest(request({}, { task: 'evaluation', query: fasta, rri: EVALUATION_EXAMPLE })), /exactly one/); - assert.throws(() => prepareRequest(request({}, { query: '>a\n'+'A'.repeat(2000)+'\n>b\n'+'A'.repeat(2000), target: 'C'.repeat(300) })), /1,000,000/); - assert.doesNotThrow(() => prepareRequest(request({ qSet: '2-2' }, { query: '>a\n'+'A'.repeat(2000)+'\n>b\n'+'A'.repeat(2000), target: 'C'.repeat(300) }))); + assert.throws(() => prepareRequest(request({}, { query: '>a\n'+'A'.repeat(5000)+'\n>b\n'+'A'.repeat(2000), target: 'C'.repeat(300) })), /1,000,000/); + assert.doesNotThrow(() => prepareRequest(request({ qSet: '2-2' }, { query: '>a\n'+'A'.repeat(5000)+'\n>b\n'+'A'.repeat(2000), target: 'C'.repeat(300) }))); assert.throws(() => prepareRequest(request({ outPairwise: true }, { query: fasta })), /same number/); assert.throws(() => prepareRequest(request({ qSet: '3-3' })), /within/); - assert.throws(() => prepareRequest(request({}, { query: 'A'.repeat(2001) }))); + assert.throws(() => prepareRequest(request({}, { query: 'A'.repeat(5001) }))); }); test('CLI defaults, ranges and strict typed controls reject invalid values', () => { @@ -85,3 +85,35 @@ test('JSON worker boundary embeds all inputs with typed non-default settings', a const worker = readFileSync(new URL('../intarna-worker.js', import.meta.url), 'utf8'); assert.ok(worker.includes("callMain(['--parameterFile=/parameter.json'])")); }); + + +test('browser batches use longest selected strands and round-trip full FASTA inputs', async () => { + const batch = Array.from({ length: 50 }, (_, i) => `>q${i}\n${'A'.repeat(5000)}\n`).join(''); + assert.equal(parseFasta(batch, 'Query').records.length, 50); + assert.throws(() => parseFasta(batch + '>extra\nAA', 'Query'), /50 FASTA/); + assert.doesNotThrow(() => prepareRequest(request({}, { query: batch, target: 'C'.repeat(200) }))); + assert.throws(() => prepareRequest(request({}, { query: batch, target: 'C'.repeat(201) })), /1,000,000/); + const crossed = { query: '>q1\n' + 'A'.repeat(5000) + '\n>q2\nAA', target: '>t1\nCC\n>t2\n' + 'C'.repeat(5000) }; + assert.throws(() => prepareRequest(request({ outPairwise: true, noSeed: true }, crossed)), /1,000,000/); + const { serializeParameters, importParameters } = await import('../parameter-file.js'); + assert.equal(importParameters(serializeParameters(request({}, { query: batch }), 'test')).query, batch); + const parsed = parseFasta('>one\tdescription here\nACGU\n>two \nACGU', 'Query'); + assert.deepEqual(parsed.records.map((record) => record.id), ['one', 'two']); + assert.equal(parsed.warnings.length, 1); + assert.match(parsed.warnings[0], /one/); + assert.equal(parsed.fasta, '>one\nACGU\n>two\nACGU\n'); +}); + +test('CSV groups cover native columns once and task defaults serialize explicitly', async () => { + assert.deepEqual(CSV_GROUPS.flatMap((group) => group.keys).toSorted(), CSV_CATALOG.map((column) => column.key).toSorted()); + const energy = [...CSV_GROUPS.find((group) => group.label === 'Energy').keys, 'Etotal', 'Eall', 'Eall1', 'Eall2', 'EallTotal']; + for (const key of energy) assert.ok(defaultColumns('evaluation').includes(key), key); + for (const key of ['seedStart1', 'seedStart2', 'seedEnd1', 'seedEnd2']) assert.ok(!defaultColumns('evaluation').includes(key), key); + for (const key of ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedStart2']) assert.ok(defaultColumns('prediction').includes(key), key); + const { exportParameters, importParameters } = await import('../parameter-file.js'); + const input = request({ outNumber: 10 }, { csvColumns: defaultColumns('prediction') }); + const saved = exportParameters(input, 'test'); + assert.equal(saved.parameters.outNumber, 10); + assert.equal(saved.parameters.outCsvCols, input.csvColumns.join(',')); + assert.deepEqual(prepareRequest(importParameters(saved)), prepareRequest(input)); +}); diff --git a/gui/tests/uploads.test.mjs b/gui/tests/uploads.test.mjs index c4c34a1d..76a13200 100644 --- a/gui/tests/uploads.test.mjs +++ b/gui/tests/uploads.test.mjs @@ -9,5 +9,7 @@ test('FASTA uploads accept text/CRLF and validate size, format and task before r assert.equal(await readFastaFile(file(multi), 'Query', 'prediction'), multi); await assert.rejects(readFastaFile(file(multi), 'Query', 'evaluation'), /exactly one/); for (const invalid of ['', '\u0000bin', '>q\n--!', '>q\n']) await assert.rejects(readFastaFile(file(invalid), 'Query', 'prediction')); - await assert.rejects(readFastaFile({ size: 60001, text: () => { throw Error('must not read'); } }, 'Query', 'prediction'), /60 KB/); + const batch = Array.from({ length: 50 }, (_, i) => `>q${i}\n${'A'.repeat(5000)}\n`).join(''); + assert.equal(await readFastaFile(file(batch), 'Query', 'prediction'), batch); + await assert.rejects(readFastaFile({ size: 1000001, text: () => { throw Error('must not read'); } }, 'Query', 'prediction'), /1 MB/); }); diff --git a/gui/uploads.js b/gui/uploads.js index 10035231..81748047 100644 --- a/gui/uploads.js +++ b/gui/uploads.js @@ -1,8 +1,8 @@ -import { parseFasta } from './protocol.js'; +import { parseFasta, LIMITS } from './protocol.js'; /** Read and validate a local text file before replacing a FASTA input. */ export async function readFastaFile(file, label, task) { - if (!file || file.size > 60000) throw new Error(`${label}: choose a FASTA text file of at most 60 KB.`); + if (!file || file.size > LIMITS.maxFastaBytes) throw new Error(`${label}: choose a FASTA text file of at most 1 MB.`); const text = await file.text(); parseFasta(text, label, task !== 'evaluation'); return text; diff --git a/gui/visualization.js b/gui/visualization.js index 46837c57..0bc7b397 100644 --- a/gui/visualization.js +++ b/gui/visualization.js @@ -27,8 +27,8 @@ export function visualizationUrl(row, request) { export class InteractionVisualization { constructor(root) { - this.frame = root.querySelector('#varri-frame'); this.link = root.querySelector('#varri-link'); - this.status = root.querySelector('#visualization-status'); + this.frame = root.querySelector('[data-role=varri-frame]'); this.link = root.querySelector('[data-role=varri-link]'); + this.status = root.querySelector('[data-role=visualization-status]'); } clear() { this.frame.removeAttribute('src'); this.frame.hidden = true; diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index a8fbdc52..040744ba 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -26,7 +26,7 @@ PYSOURCE vienna_source="$build/sources/vienna-$vienna_version-$vienna_sha256/ViennaRNA-$vienna_version" mkdir -p "$site/licenses" cp -R "$repo/gui/assets" "$repo/gui/examples" "$site/" -for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js visualization.js parameter-file.js uploads.js; do +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js parameter-file.js uploads.js; do cp "$repo/gui/$file" "$site/$file" done cp "$repo/gui/intarna-worker.js" "$site/intarna-worker.js" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index a9af63f8..990e5e58 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -29,7 +29,9 @@ def viewer(route): page.wait_for_load_state('networkidle') compute = page.locator('#compute') status = page.locator('#result-status') - rows = page.locator('tbody tr') + sessions = page.locator('.session') + active = page.locator('.session:has([data-role=panel]:not([hidden]))') + rows = active.locator('tbody tr') def open_group(key): page.locator('#tab-' + key).click() @@ -42,9 +44,9 @@ def load_example(task='prediction'): page.get_by_role('button', name='Evaluate a duplex' if task == 'evaluation' else 'Short duplex', exact=False).click() expect(page.locator('#query')).to_have_value('>query\nGGGAGGAGGG\n') - def download_archive(): + def download_archive(session=None): with page.expect_download() as downloaded: - page.locator('#download-zip').click() + (active if session is None else session).locator('[data-role=download-zip]').click() with ZipFile(BytesIO(Path(downloaded.value.path()).read_bytes())) as archive: assert archive.testzip() is None return {name: archive.read(name).decode() for name in archive.namelist()} @@ -56,9 +58,11 @@ def fill(key, value): page.locator('#' + key).fill(str(value)) def predict(): + count = sessions.count() compute.click() expect(status).to_contain_text('complete:', timeout=120000) expect(page.locator('#input-error')).not_to_be_visible() + expect(sessions).to_have_count(count + 1) expect(page.locator('#query')).to_have_value('') expect(page.locator('#target')).to_have_value('') @@ -72,7 +76,7 @@ def predict(): expect(page.locator('#version-info')).to_contain_text(f'ViennaRNA {vienna_version}') for logo in page.locator('.site-header img').all(): assert logo.evaluate('node => node.complete && node.naturalWidth > 0') - for key in ['query-options', 'target-options', 'seed-options', 'helix-options', 'shape-options', 'interaction-options', 'output-options', 'general-options', 'sequence-input']: + for key in ['seed-options', 'output-options', 'interaction-options', 'query-options', 'target-options', 'general-options', 'sequence-input']: open_group(key) expect(page.get_by_role('radio', name='Prediction')).to_be_checked() @@ -87,13 +91,90 @@ def predict(): # Tabs support keyboard navigation and wrap without page overflow. page.locator('#tab-sequence-input').focus() page.keyboard.press('ArrowRight') - expect(page.locator('#tab-query-options')).to_be_focused() - expect(page.locator('#query-options')).to_be_visible() + expect(page.locator('#tab-seed-options')).to_be_focused() + expect(page.locator('#seed-options')).to_be_visible() page.keyboard.press('End') expect(page.locator('#tab-general-options')).to_be_focused() page.keyboard.press('Home') expect(page.locator('#tab-sequence-input')).to_be_focused() + # Review layout, task defaults and modes work with native parameters. + assert page.locator('[role=tab]').all_text_contents() == ['Sequences', 'Seed', 'Output', 'Model & Energy', 'Query', 'Target', 'CLI-only'] + expect(page.locator('.brand')).to_contain_text('IntaRNA package') + expect(page.locator('#results-title')).to_have_text('Results') + expect(page.locator('#outNumber')).to_have_value('10') + assert page.locator('.compute-bar').evaluate('node => node.firstElementChild.id') == 'compute' + assert page.locator('.sequence-meta').first.evaluate('node => node.firstElementChild.tagName') == 'LABEL' + fill('query', '>named\tignored description\n' + 'G' * 1000 + '\n' * 20 + 'GG') + expect(page.locator('#query-warning')).to_contain_text('ignoring FASTA description') + expect(page.locator('#query-warning')).to_contain_text('named') + assert page.locator('#query').evaluate('node => node.scrollWidth > node.clientWidth && node.scrollHeight > node.clientHeight') + fill('query', '>named \nGGGAGGAGGG') + expect(page.locator('#query-warning')).not_to_be_visible() + + for prefix, group in [('q', 'query-options'), ('t', 'target-options')]: + open_group(group) + # Each explicit row shares label height, including wrapped labels. + for keys in [[prefix + suffix for suffix in ['Set', 'Region', 'IntLenMax', 'IntLoopMax', 'RegionLenMax']], [prefix + suffix for suffix in ['Acc', 'AccW', 'AccL', 'PfScale']]]: + tops = [page.locator('#' + key).bounding_box()['y'] for key in keys] + assert max(tops) - min(tops) < 1, tops + expect(page.locator('#' + prefix + 'AccFile')).to_be_disabled() + page.locator('#' + prefix + 'Acc').select_option('P') + expect(page.locator('#' + prefix + 'AccFile')).to_be_enabled() + for suffix in ['AccW', 'AccL']: + expect(page.locator('#' + prefix + suffix)).to_be_disabled() + page.locator('#' + prefix + 'Acc').select_option('N') + for suffix in ['AccW', 'AccL', 'AccConstr', 'AccFile', 'PfScale', 'Shape', 'ShapeMethod', 'ShapeConversion']: + expect(page.locator('#' + prefix + suffix)).to_be_disabled() + expect(page.locator('input[data-for="' + prefix + 'Shape"]')).to_be_disabled() + page.locator('#' + prefix + 'Acc').select_option('C') + expect(page.locator('#' + prefix + 'Shape')).to_be_enabled() + page.locator('#accNoLP-target').check() + expect(page.locator('#accNoLP')).to_be_checked() + open_group('query-options') + page.locator('#accNoLP').uncheck() + expect(page.locator('#accNoLP-target')).not_to_be_checked() + open_group('interaction-options') + expect(page.locator('#helixMinBP')).to_be_disabled() + page.locator('#model').select_option('B') + expect(page.locator('#helixMinBP')).to_be_enabled() + page.locator('#model').select_option('X') + expect(page.locator('#helixMinBP')).to_be_disabled() + open_group('seed-options') + expect(page.locator('#seedTQ')).to_be_disabled() + page.locator('#seed-mode').select_option('none') + for key in ['seedBP', 'seedTQ', 'seedQRange', 'seedNoGU']: + expect(page.locator('#' + key)).to_be_disabled() + page.locator('#seed-mode').select_option('explicit') + expect(page.locator('#seedTQ')).to_be_enabled() + expect(page.locator('#seedBP')).to_be_disabled() + page.locator('#seed-mode').select_option('identify') + expect(page.locator('#seedBP')).to_be_enabled() + expect(page.locator('#seedTQ')).to_be_disabled() + open_group('output-options') + groups = page.locator('.csv-group') + assert groups.count() == 5 + for group in groups.all(): + group.get_by_role('button', name=re.compile('^Select all')).click() + assert group.locator('input:not(:checked)').count() == 0 + group.get_by_role('button', name='Deselect all').click() + assert group.locator('input:checked').count() == 0 + page.get_by_role('button', name='Task defaults', exact=True).click() + for key in ['ED1', 'ED2', 'seedE', 'seedStart1', 'seedStart2']: + expect(page.locator('.csv-group input[value="' + key + '"]')).to_be_checked() + assert all(label.get_attribute('title') and ' — ' not in label.inner_text() for label in groups.locator('label').all()) + # Inspect all changed panels at desktop and mobile widths. + for width in [1440, 390]: + page.set_viewport_size({'width': width, 'height': 1100 if width == 1440 else 844}) + for key in ['sequence-input', 'seed-options', 'output-options', 'interaction-options', 'query-options', 'target-options', 'general-options']: + open_group(key) + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth'), (width, key) + if args.screenshot: + path = Path(args.screenshot) + page.screenshot(path=str(path.with_name(f'{path.stem}-{key}-{width}.png')), full_page=True) + page.set_viewport_size({'width': 1440, 'height': 1100}) + page.locator('#reset').click() + # Upload and file drop use local text, validate before replacing, and reset. with page.expect_file_chooser() as chooser: page.locator('#query-upload-button').click() @@ -131,43 +212,83 @@ def predict(): expect(page.locator('#rri')).to_have_value('') load_example() + # Disabled stale edits neither block a run nor leak into JSON. Re-enabling + # restores local edits; loading/storing uses effective native settings. + fill('seedBP', '') + page.locator('#seed-mode').select_option('explicit') + compute.click() + expect(page.locator('#input-error')).to_contain_text('Explicit seed: enter') + fill('seedTQ', '1|||&1|||') + predict() + explicit_settings = json.loads(download_archive()['parameter.json']) + assert explicit_settings['parameters']['seedTQ'] == '1|||&1|||' + assert 'seedBP' not in explicit_settings['parameters'] + with page.expect_download() as stored: + page.locator('#store-parameters').click() + explicit_file = Path(stored.value.path()).read_bytes() + page.locator('#reset').click() + page.locator('#parameter-upload').set_input_files({'name': 'explicit.json', 'mimeType': 'application/json', 'buffer': explicit_file}) + expect(page.locator('#seed-mode')).to_have_value('explicit') + open_group('seed-options') + page.locator('#seedTQ').fill('invalid inactive seed') + page.locator('#seed-mode').select_option('none') + fill('qShape', 'invalid inactive shape') + page.locator('#qAcc').select_option('N') + open_group('target-options') + page.locator('#accNoGUend-target').check() + predict() + inactive_settings = json.loads(download_archive()['parameter.json'])['parameters'] + assert inactive_settings['noSeed'] is True + assert inactive_settings['qAcc'] == 'N' + assert inactive_settings['accNoGUend'] is True + assert 'qShape' not in inactive_settings and 'seedTQ' not in inactive_settings + open_group('query-options') + page.locator('#qAcc').select_option('C') + expect(page.locator('#qShape')).to_have_value('invalid inactive shape') + page.locator('#reset').click() + load_example() + # Reference prediction, selectable CSV output and independent table visibility. fill('outNumber', 10) page.locator('#outCsvSort').select_option('E') predict() expect(rows).to_have_count(10) assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '1', '10', 'query', '1', '10'] - assert rows.first.locator('td').all_text_contents()[-2:] == ['-21.40', '0.00'] + assert rows.first.locator('td').nth(9).inner_text() == '-21.40' + assert rows.first.locator('td').last.inner_text() == '0.00' page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() assert float(rows.first.locator('td').nth(9).inner_text()) == -14.3 page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() - page.locator('#search').fill('-21.4') + active.locator('[data-role=search]').fill('-21.4') expect(rows).to_have_count(1) - page.locator('#search').fill('no match') - expect(page.locator('#no-matches')).to_be_visible() - page.locator('#search').fill('') - page.locator('.column-picker summary').click() - page.locator('#visible-columns input[value="start1"]').uncheck() - assert 'Target start' not in page.locator('thead').inner_text() - page.locator('#visible-columns input[value="start1"]').check() + active.locator('[data-role=search]').fill('no match') + expect(active.locator('[data-role=no-matches]')).to_be_visible() + active.locator('[data-role=search]').fill('') + active.locator('.column-picker summary').click() + active.locator('[data-role=visible-columns] input[value="start1"]').uncheck() + assert 'Target start' not in active.locator('thead').inner_text() + active.locator('[data-role=visible-columns] input[value="start1"]').check() with page.expect_download() as downloaded: - page.locator('#download').click() + active.locator('[data-role=download]').click() csv = Path(downloaded.value.path()).read_text() - assert csv.splitlines()[0] == 'id1;start1;end1;id2;start2;end2;subseqDP;hybridDP;E' + assert csv.splitlines()[0] == 'id1;start1;end1;id2;start2;end2;subseqDP;hybridDP;E;ED1;ED2;seedE;seedStart1;seedStart2' assert 'target;1;10;query;1;10;' in csv assert 'deltaE' not in csv + assert active.locator('thead th').first.inner_text() == '' + assert active.locator('thead th button br').count() >= 4 + assert rows.first.locator('td').first.evaluate('node => getComputedStyle(node).paddingLeft') == '5px' assert not visualization_urls # Third-party content loads only on selection. rows.first.get_by_role('button', name='Visualize').click() - expect(page.locator('#varri-frame')).to_be_visible() - expect(page.frame_locator('#varri-frame').get_by_role('heading', name='vaRRI viewer')).to_be_visible() + expect(active.locator('[data-role=varri-frame]')).to_be_visible() + expect(active.frame_locator('[data-role=varri-frame]').get_by_role('heading', name='vaRRI viewer')).to_be_visible() expect(rows.first.locator('button')).to_have_attribute('aria-pressed', 'true') - query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + query = parse_qs(urlparse(active.locator('[data-role=varri-frame]').get_attribute('src')).query) assert query['sequence'] == ['CCCUCCUCCC&GGGAGGAGGG'] assert query['structure'] == ['((((((((((&))))))))))'] assert query['seqName1'] == ['target'] and query['seqName2'] == ['query'] assert query['startIndex1'] == ['1'] assert query['showRenderingOnly'] == ['true'] - assert 'showRenderingOnly' not in page.locator('#varri-link').get_attribute('href') + assert 'showRenderingOnly' not in active.locator('[data-role=varri-link]').get_attribute('href') archive = download_archive() assert archive['intarna-interactions.csv'] == csv saved = json.loads(archive['parameter.json']) @@ -187,7 +308,7 @@ def predict(): imported['parameters']['temperature'] = 24 page.locator('#parameter-upload').set_input_files({'name': 'settings.json', 'mimeType': 'application/json', 'buffer': json.dumps(imported).encode()}) expect(page.locator('#temperature')).to_have_value('24') - expect(page.locator('#results-view')).not_to_be_visible() + expect(active.locator('[data-role=results-view]')).to_be_visible() with page.expect_download() as stored_again: page.locator('#store-parameters').click() assert json.loads(Path(stored_again.value.path()).read_text())['parameters']['outCsvCols'] == 'E,id2' @@ -207,6 +328,7 @@ def predict(): # Multi-FASTA prediction, signed inclusive output and additional files. load_example() + fill('outNumber', 1) fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') fill('qIdxPos0', -3) fill('tIdxPos0', -5) @@ -215,16 +337,16 @@ def predict(): predict() expect(rows).to_have_count(2) assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '-5', '5', 'q1', '-3', '7'] - expect(page.locator('#file-list details')).to_have_count(2) - page.locator('#file-list summary').first.click() - expect(page.locator('#file-list pre').first).to_contain_text('minE') + expect(active.locator('[data-role=file-list] details')).to_have_count(2) + active.locator('[data-role=file-list] summary').first.click() + expect(active.locator('[data-role=file-list] pre').first).to_contain_text('minE') archive = download_archive() assert len([name for name in archive if name.startswith('qMinE')]) == 2 saved = json.loads(archive['parameter.json']) assert saved['parameters']['query']['text'].count('>') == 2 assert saved['parameters']['qIdxPos0'] == -3 rows.nth(1).get_by_role('button', name='Visualize').click() - query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + query = parse_qs(urlparse(active.locator('[data-role=varri-frame]').get_attribute('src')).query) assert query['seqName2'] == ['q2'] assert query['startIndex1'] == ['-5'] and query['startIndex2'] == ['-3'] @@ -232,9 +354,9 @@ def predict(): fill('query', '>q first\nGGGAGGAGGG\n>q second\nGGGAGGAGGG') compute.click() expect(page.locator('#input-error')).to_contain_text('unique') - expect(page.locator('#varri-frame')).not_to_be_visible() - assert page.locator('#varri-frame').get_attribute('src') is None - expect(page.locator('#download-zip')).not_to_be_visible() + expect(active.locator('[data-role=varri-frame]')).to_be_visible() + expect(active.locator('[data-role=download-zip]')).to_be_visible() + assert download_archive() == archive # Evaluation resets native defaults and removes prediction-only controls. fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') @@ -252,12 +374,12 @@ def predict(): predict() expect(status).to_contain_text('Evaluation complete') expect(rows).to_have_count(2) - expect(page.locator('thead')).to_contain_text('ED1') - expect(page.locator('#file-list summary')).to_contain_text('intarna-detailed.txt') - page.locator('#file-list summary').click() - expect(page.locator('#file-list pre')).to_contain_text('-21.4') + expect(active.locator('thead')).to_contain_text('ED1') + expect(active.locator('[data-role=file-list] summary')).to_contain_text('intarna-detailed.txt') + active.locator('[data-role=file-list] summary').click() + expect(active.locator('[data-role=file-list] pre')).to_contain_text('-21.4') with page.expect_download() as report: - page.locator('#file-list button').click() + active.locator('[data-role=file-list] button').click() assert '-21.4' in Path(report.value.path()).read_text() archive = download_archive() assert '-21.4' in archive['intarna-detailed.txt'] @@ -273,7 +395,7 @@ def predict(): page.locator('#output-options input[value="E"]').check() predict() rows.first.get_by_role('button', name='Visualize').click() - expect(page.locator('#varri-frame')).to_be_visible() + expect(active.locator('[data-role=varri-frame]')).to_be_visible() archive = download_archive() assert archive['intarna-interactions.csv'].splitlines()[0] == 'E' assert json.loads(archive['parameter.json'])['parameters']['outCsvCols'] == 'E' @@ -285,11 +407,13 @@ def predict(): page.get_by_role('radio', name='Prediction').check() load_example() held_routes = [] + session_count = sessions.count() page.route('**/wasm/intarna.wasm', lambda route: held_routes.append(route)) compute.click() expect(page.locator('#loading')).to_be_visible() page.locator('#abort').click() expect(status).to_contain_text('aborted') + expect(sessions).to_have_count(session_count) for route in held_routes: route.abort() page.unroute('**/wasm/intarna.wasm') @@ -299,6 +423,7 @@ def predict(): expect(page.locator('#loading-status')).to_contain_text('Predicting RNA', timeout=120000) page.keyboard.press('Escape') expect(status).to_contain_text('aborted') + expect(sessions).to_have_count(session_count) load_example() predict() @@ -306,21 +431,177 @@ def predict(): compute.click() expect(status).to_contain_text('No favorable interactions', timeout=120000) expect(rows).to_have_count(0) - expect(page.locator('#varri-frame')).not_to_be_visible() + expect(active.locator('[data-role=varri-frame]')).not_to_be_visible() assert download_archive()['intarna-interactions.csv'].count('\n') == 1 fill('query', '>\nGGGAGGAGGG') predict() - assert page.locator('tbody img').count() == 0 + assert active.locator('tbody img').count() == 0 assert ' 1 + page.reload() + page.wait_for_load_state('networkidle') + expect(sessions).to_have_count(0) + load_example() + fill('temperature', 24) + fill('qIdxPos0', -3) + fill('tIdxPos0', -5) + fill('qShape', '1 0.2\n2 0.3') + fill('outNumber', 3) + open_group('output-options') + page.locator('#output-options input[value="qMinE"]').check() + predict() + first_id = active.get_attribute('data-session-id') + first = page.locator(f'[data-session-id="{first_id}"]') + first.get_by_label('Session name', exact=True).fill('Cool duplex') + first.get_by_label('Session name', exact=True).press('Enter') + first_archive = download_archive(first) + first_csv = first_archive['intarna-interactions.csv'] + first.locator('[data-role=search]').fill('no match') + expect(first.locator('[data-role=no-matches]')).to_be_visible() + first.locator('.column-picker summary').click() + first.locator('[data-role=visible-columns] input[value="start1"]').uncheck() + first_diagnostics = first.locator('[data-role=diagnostic-text]').text_content() + + # Edits and Reset preserve outputs. Restore also works for an unfolded run. + fill('temperature', 31) + expect(first.locator('[data-role=panel]')).to_be_visible() + assert download_archive(first) == first_archive + first.locator('[data-role=summary]').click() + expect(page.locator('#temperature')).to_have_value('24') + fill('temperature', 31) + first.locator('[data-role=toggle]').click() + expect(page.locator('#temperature')).to_have_value('24') + expect(first.locator('[data-role=panel]')).to_be_visible() + first.locator('[data-role=toggle]').click() + expect(first.locator('[data-role=panel]')).not_to_be_visible() + first.locator('[data-role=toggle]').press('Enter') + expect(first.locator('[data-role=panel]')).to_be_visible() + page.locator('#reset').click() + expect(sessions).to_have_count(1) + expect(page.locator('#query')).to_have_value('') + assert download_archive(first) == first_archive + + # A different task, CSV order and output report own a second result area. + load_example('evaluation') + with page.expect_download() as stored: + page.locator('#store-parameters').click() + second_settings = json.loads(Path(stored.value.path()).read_text()) + second_settings['parameters']['temperature'] = 42 + second_settings['parameters']['outCsvCols'] = 'E,id2' + second_settings['gui'] = {'reports': ['detailed']} + page.locator('#parameter-upload').set_input_files({'name': 'evaluation.json', 'mimeType': 'application/json', 'buffer': json.dumps(second_settings).encode()}) + predict() + expect(sessions).to_have_count(2) + second_id = active.get_attribute('data-session-id') + assert second_id != first_id + second = page.locator(f'[data-session-id="{second_id}"]') + expect(first.locator('[data-role=panel]')).not_to_be_visible() + expect(first.locator('[data-role=download-zip]')).to_be_visible() + assert download_archive(first) == first_archive + expect(page.get_by_role('radio', name='Evaluation')).to_be_checked() + expect(page.locator('#temperature')).to_have_value('42') + second_archive = download_archive(second) + assert second_archive['intarna-interactions.csv'].splitlines()[0] == 'E;id2' + assert 'intarna-detailed.txt' in second_archive + + # Selecting reloads all computed inputs while retaining each table's filter. + first.locator('.session-header').focus() + first.locator('.session-header').press('Space') + expect(second.locator('[data-role=panel]')).not_to_be_visible() + expect(first.locator('[data-role=panel]')).to_be_visible() + assert first.locator('.session-header').evaluate('node => node.firstElementChild.dataset.role') == 'toggle' + expect(first.locator('[data-role=toggle]')).to_have_text('Hide') + expect(page.get_by_role('radio', name='Prediction')).to_be_checked() + expect(page.locator('#qIdxPos0')).to_have_value('-3') + expect(page.locator('#tIdxPos0')).to_have_value('-5') + expect(page.locator('#qShape')).to_have_value('1 0.2\n2 0.3') + expect(page.locator('#temperature')).to_have_value('24') + expect(page.locator('#output-options input[value="qMinE"]')).to_be_checked() + expect(page.locator('#output-options input[value="detailed"]')).not_to_be_checked() + expect(first.locator('[data-role=search]')).to_have_value('no match') + expect(first.locator('[data-role=visible-columns] input[value="start1"]')).not_to_be_checked() + assert first.locator('[data-role=diagnostic-text]').text_content() == first_diagnostics + with page.expect_download() as restored: + page.locator('#store-parameters').click() + assert Path(restored.value.path()).read_text() == first_archive['parameter.json'] + first.locator('[data-role=search]').fill('') + expect(first.locator('tbody tr')).to_have_count(3) + first.locator('tbody tr').first.get_by_role('button', name='Visualize').click() + first_url = first.locator('[data-role=varri-frame]').get_attribute('src') + assert parse_qs(urlparse(first_url).query)['startIndex1'] == ['-5'] + + # A pending file read must not overwrite a selected session's snapshot. + page.evaluate(r"""() => { window.originalFileText = File.prototype.text; File.prototype.text = function() { return new Promise(resolve => { window.finishRead = () => resolve('>late\nGGGAGGAGGG\n'); }); }; }""") + page.locator('#query-upload').set_input_files({'name': 'late.fa', 'mimeType': 'text/plain', 'buffer': b'>late\nGGGAGGAGGG\n'}) + second.locator('[data-role=toggle]').click() + page.evaluate('() => { File.prototype.text = window.originalFileText; window.finishRead(); }') + expect(page.locator('#query')).to_have_value('>query\nGGGAGGAGGG\n') + expect(page.locator('#qShape')).to_have_value('') + expect(page.locator('#temperature')).to_have_value('42') + expect(page.get_by_role('radio', name='Evaluation')).to_be_checked() + expect(page.locator('#rri')).to_have_value(second_settings['parameters']['rri']) + expect(page.locator('#output-options input[value="detailed"]')).to_be_checked() + assert first.locator('[data-role=varri-frame]').get_attribute('src') is None + with page.expect_download() as restored: + page.locator('#store-parameters').click() + assert Path(restored.value.path()).read_text() == second_archive['parameter.json'] + assert download_archive(first)['intarna-interactions.csv'] == first_csv + + # Names are literal text and cannot introduce markup, duplicate IDs or paths. + first.get_by_label('Session name', exact=True).fill(' / β') + first.get_by_label('Session name', exact=True).press('Enter') + expect(page.locator('#temperature')).to_have_value('24') + expect(first.locator('[data-role=panel]')).to_be_visible() + assert first.locator('img').count() == 0 + with page.expect_download() as renamed: + first.locator('[data-role=download-zip]').click() + assert '/' not in renamed.value.suggested_filename + assert '\\' not in renamed.value.suggested_filename + ids = page.locator('[id]').evaluate_all('nodes => nodes.map(node => node.id)') + assert len(ids) == len(set(ids)) + first.get_by_label('Session name', exact=True).fill('') + first.get_by_label('Session name', exact=True).press('Enter') + expect(first.get_by_label('Session name', exact=True)).to_have_value('Prediction 1') + first.get_by_label('Session name', exact=True).fill('Cool duplex — 24 °C') + first.get_by_label('Session name', exact=True).press('Enter') + if args.screenshot: + page.screenshot(path=args.screenshot, full_page=True) + page.set_viewport_size({'width': 390, 'height': 844}) + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + for session in [first, second]: + expect(session.locator('[data-role=download-zip]')).to_be_visible() + expect(session.locator('[data-role=delete]')).to_be_visible() + page.set_viewport_size({'width': 1440, 'height': 1100}) + + # Delete releases only that output; current inputs and other sessions survive. + second.locator('[data-role=toggle]').click() + first.locator('[data-role=delete]').click() + expect(sessions).to_have_count(1) + expect(page.locator('#temperature')).to_have_value('42') + assert download_archive(second) == second_archive + second.locator('[data-role=delete]').click() + expect(sessions).to_have_count(0) + expect(compute).to_be_focused() + expect(page.locator('#temperature')).to_have_value('42') + predict() + assert active.get_attribute('data-session-id') not in [first_id, second_id] + assert download_archive() == second_archive + page.reload() + page.wait_for_load_state('networkidle') + expect(sessions).to_have_count(0) assert errors == [], errors browser.close() -print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, tabs/keyboard, visualization URLs/selection, ZIP replay contents, columns/reports, mobile and abort/recovery passed') +print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, tabs/keyboard, visualization URLs/selection, ZIP replay contents, columns/reports, mobile, abort/recovery and retained session lifecycle passed')