From 2d4c6fca14af916808df9886cfb687cfe9b854c2 Mon Sep 17 00:00:00 2001 From: elkimek <36666630+elkimek@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:27:40 +0200 Subject: [PATCH 1/2] Extract DNA runtime wiring --- js/dna-runtime.js | 128 +++++++++++++++++++++++ js/dna.js | 70 ++++++------- scripts/quality-baseline.json | 2 +- service-worker.js | 1 + tests/_vitest-legacy.test.js | 1 + tests/test-dna-runtime.js | 174 +++++++++++++++++++++++++++++++ tests/test-dna.js | 9 ++ tests/test-quality-guardrails.js | 1 + tsconfig.checkjs.json | 1 + tsconfig.json | 1 + version.js | 2 +- 11 files changed, 353 insertions(+), 37 deletions(-) create mode 100644 js/dna-runtime.js create mode 100644 tests/test-dna-runtime.js diff --git a/js/dna-runtime.js b/js/dna-runtime.js new file mode 100644 index 00000000..404f344e --- /dev/null +++ b/js/dna-runtime.js @@ -0,0 +1,128 @@ +// @ts-check +// dna-runtime.js - Browser runtime adapters for DNA import and shell refresh flows. + +import { installDNAWindowBindings } from './dna-window-bindings.js'; + +function getRuntimeWindow() { + return typeof window !== 'undefined' + ? /** @type {any} */ (window) + : /** @type {any} */ (globalThis); +} + +/** + * @param {string} name + * @returns {Function | null} + */ +function getRuntimeFunction(name) { + const runtime = getRuntimeWindow(); + return typeof runtime[name] === 'function' ? runtime[name].bind(runtime) : null; +} + +/** @returns {boolean} */ +function isDnaDebugMode() { + try { + return getRuntimeFunction('isDebugMode')?.() === true; + } catch { + return false; + } +} + +/** @param {any} data */ +export function cacheDnaSnpTable(data) { + getRuntimeWindow()._snpTableCache = data; +} + +/** @param {...any} args */ +export function logDnaDebugError(...args) { + if (isDnaDebugMode()) console.error(...args); +} + +/** @param {...any} args */ +export function logDnaDebugWarn(...args) { + if (isDnaDebugMode()) console.warn(...args); +} + +/** @param {string} route */ +export function navigateDnaRoute(route) { + getRuntimeFunction('navigate')?.(route); +} + +export function refreshDnaSidebar() { + const buildSidebar = getRuntimeFunction('buildSidebar'); + if (!buildSidebar) return; + try { + buildSidebar(); + } catch {} +} + +/** @param {string} route */ +export function refreshDnaShell(route) { + refreshDnaSidebar(); + navigateDnaRoute(route); +} + +/** @returns {boolean} */ +export function isDnaLabImportRunning() { + try { + return getRuntimeFunction('isImportRunning')?.() === true; + } catch { + return false; + } +} + +/** @param {File} file */ +export function callDnaFileHandler(file) { + getRuntimeFunction('handleDNAFile')?.(file); +} + +export function triggerDnaFilePicker() { + getRuntimeFunction('triggerDNAFilePicker')?.(); +} + +export function updateDnaChatNudge() { + getRuntimeFunction('updateChatNudge')?.(); +} + +/** @returns {Promise} */ +export async function confirmDnaDeleteDialog() { + const confirmDialog = getRuntimeFunction('showConfirmDialog'); + if (!confirmDialog) return false; + try { + return await confirmDialog('Delete genetic data? This cannot be undone.') === true; + } catch { + return false; + } +} + +/** @returns {{ importedData?: any } | null} */ +export function getDnaRuntimeState() { + try { + return getRuntimeFunction('_getState')?.() || null; + } catch { + return null; + } +} + +/** @returns {Promise} */ +export async function saveDnaRuntimeAndRefresh() { + await getRuntimeFunction('_saveAndRefresh')?.(); +} + +/** @param {any} result */ +export function setPendingDnaImport(result) { + getRuntimeWindow()._pendingDNAImport = result; +} + +/** @returns {any} */ +export function getPendingDnaImport() { + return getRuntimeWindow()._pendingDNAImport || null; +} + +export function clearPendingDnaImport() { + getRuntimeWindow()._pendingDNAImport = null; +} + +/** @param {Record} bindings */ +export function publishDnaWindowBindings(bindings) { + installDNAWindowBindings(getRuntimeWindow(), bindings); +} diff --git a/js/dna.js b/js/dna.js index 22da998b..0d451f2c 100644 --- a/js/dna.js +++ b/js/dna.js @@ -8,7 +8,14 @@ import { saveImportedData } from './data.js'; import { closeModalOverlay, openModalOverlay } from './modal-lifecycle.js'; import { dnaActionAttrs, initDnaActionDelegates } from './dna-actions.js'; import { findGenotypeInfo as findGenotypeInfoImpl, findSnpHint as findSnpHintImpl, sortAlleles } from './dna-genotype.js'; -import { installDNAWindowBindings } from './dna-window-bindings.js'; +import { + cacheDnaSnpTable, callDnaFileHandler, clearPendingDnaImport, + confirmDnaDeleteDialog, getDnaRuntimeState, getPendingDnaImport, + isDnaLabImportRunning, logDnaDebugError, logDnaDebugWarn, + navigateDnaRoute, publishDnaWindowBindings, refreshDnaShell, + refreshDnaSidebar, saveDnaRuntimeAndRefresh, setPendingDnaImport, + triggerDnaFilePicker, updateDnaChatNudge, +} from './dna-runtime.js'; import { HAPLOGROUP_LIST, closeMtDNAPreview, @@ -22,12 +29,6 @@ import { resolveHaplogroup, setManualHaplogroup, } from './dna-mtdna.js'; -/** @typedef {Window & typeof globalThis & { - * _pendingDNAImport?: any, - * _getState: () => { importedData: any }, - * _saveAndRefresh: () => Promise | void - * }} DnaWindow */ -const dnaWindow = /** @type {DnaWindow} */ (window); // ═══════════════════════════════════════════════ // FORMAT DETECTION // ═══════════════════════════════════════════════ @@ -238,8 +239,8 @@ function loadSNPTable({ forceFresh = false } = {}) { if (!_snpTablePromise) { _snpTablePromise = fetch('data/snp-health.json', forceFresh ? { cache: 'no-store' } : undefined) .then(r => r.json()) - .then(data => { _snpTable = data; window._snpTableCache = data; return data; }) - .catch(err => { _snpTablePromise = null; if (window.isDebugMode?.()) console.error('Failed to load SNP table:', err); throw err; }); + .then(data => { _snpTable = data; cacheDnaSnpTable(data); return data; }) + .catch(err => { _snpTablePromise = null; logDnaDebugError('Failed to load SNP table:', err); throw err; }); } return _snpTablePromise; } @@ -840,7 +841,7 @@ export function renderGeneticsSection() { `; } if (hasSnps && !_snpTable) { - loadSNPTable().then(() => { if (window.navigate) window.navigate('dashboard'); }); + loadSNPTable().then(() => navigateDnaRoute('dashboard')); return ''; } const snpTable = _snpTable; @@ -1095,7 +1096,7 @@ function reimportDNA() { input.accept = '.txt,.csv'; input.onchange = () => { const file = input.files?.[0]; - if (file && window.handleDNAFile) window.handleDNAFile(file); + if (file) callDnaFileHandler(file); }; input.click(); } @@ -1142,7 +1143,7 @@ function openDnaModalOverlay(overlay, initialFocus) { async function openManualSnpModal() { await loadSNPTable(); - dnaWindow._pendingDNAImport = null; + clearPendingDnaImport(); const html = `
Add SNPs manually
@@ -1216,9 +1217,9 @@ async function saveManualSnpFromModal() { closeModalOverlay('dna-modal-overlay'); const errorSuffix = errors.length ? ` (${errors.length} skipped)` : ''; showNotification(`Saved ${accepted.length} SNP${accepted.length === 1 ? '' : 's'}${errorSuffix}`, errors.length ? 'info' : 'success', 5000); - if (errors.length && window.isDebugMode?.()) console.warn('Manual SNP import skipped rows:', errors); - if (window.buildSidebar) try { window.buildSidebar(); } catch (e) {} - if (window.navigate) window.navigate('genome'); + if (errors.length) logDnaDebugWarn('Manual SNP import skipped rows:', errors); + refreshDnaSidebar(); + navigateDnaRoute('genome'); } async function importSnpReport() { @@ -1255,7 +1256,7 @@ async function handleSnpReportFile(file) { } showDNAImportPreview(result, file.name); } catch (e) { - if (window.isDebugMode?.()) console.error('SNP report import error:', e); + logDnaDebugError('SNP report import error:', e); showNotification(e.message || 'Failed to read SNP report', 'error'); _dnaImportRunning = false; } @@ -1269,7 +1270,7 @@ let _dnaImportRunning = false; export async function handleDNAFile(file) { if (_dnaImportRunning) { showNotification('DNA import already in progress', 'info'); return; } - if (window.isImportRunning && window.isImportRunning()) { showNotification('Lab import in progress — wait for it to finish', 'info'); return; } + if (isDnaLabImportRunning()) { showNotification('Lab import in progress — wait for it to finish', 'info'); return; } _dnaImportRunning = true; try { showNotification('Parsing DNA file...', 'info'); @@ -1281,14 +1282,14 @@ export async function handleDNAFile(file) { } showDNAImportPreview(result, file.name); } catch (e) { - if (window.isDebugMode?.()) console.error('DNA import error:', e); + logDnaDebugError('DNA import error:', e); showNotification(e.message || 'Failed to parse DNA file', 'error'); _dnaImportRunning = false; } } function showDNAImportPreview(result, fileName) { - dnaWindow._pendingDNAImport = result; + setPendingDnaImport(result); // Categorize matches by effect — skip raw APOE components when haplotype resolved const apoe = resolveAPOE(result.matches); @@ -1376,14 +1377,14 @@ function showDNAImportPreview(result, fileName) { } function closeDNAImportPreview() { - dnaWindow._pendingDNAImport = null; + clearPendingDnaImport(); _dnaImportRunning = false; closeModalOverlay('dna-modal-overlay'); if (_dnaModalEscapeBound) { document.removeEventListener('keydown', handleDnaModalEscape); _dnaModalEscapeBound = false; } } async function confirmDNAImport() { - const result = dnaWindow._pendingDNAImport; + const result = getPendingDnaImport(); if (!result) return; const originalData = state.importedData; const draftData = JSON.parse(JSON.stringify(originalData || {})); @@ -1398,7 +1399,7 @@ async function confirmDNAImport() { state.importedData = originalData; _dnaImportRunning = false; return; } - dnaWindow._pendingDNAImport = null; + clearPendingDnaImport(); _dnaImportRunning = false; closeModalOverlay('dna-modal-overlay'); showNotification(`Imported ${result.coverage.found} SNPs from ${result.source}`, 'success'); @@ -1430,8 +1431,8 @@ async function confirmDNAImport() { dnaEl.innerHTML = `

\uD83E\uDDEC ${result.coverage.found} SNPs imported from ${escapeHTML(result.source)}

${parts.join('  \u00B7  ')}
I'll factor these into all your lab interpretations.
`; - } else if (window.updateChatNudge) { - window.updateChatNudge(); + } else { + updateDnaChatNudge(); } // Refresh sidebar (genetics nav count) AND dashboard. Without the @@ -1440,8 +1441,7 @@ async function confirmDNAImport() { // Symptom that surfaced this: after re-importing on the same device // the dashboard correctly showed "43 SNPs" while the sidebar still // said "🧬 Genetics 40". - if (window.buildSidebar) try { window.buildSidebar(); } catch (e) {} - if (window.navigate) window.navigate('dashboard'); + refreshDnaShell('dashboard'); } // ═══════════════════════════════════════════════ @@ -1482,20 +1482,20 @@ export { setManualHaplogroup, }; -/// Custom confirm dialog so the destructive Delete on the genetics -/// card matches the rest of the app's modal styling and respects the -/// PWA / file:// contexts where the native window.confirm prompt -/// would feel out of place. +/// Custom confirm dialog so the destructive Delete on the genetics card +/// matches the rest of the app's modal styling and respects PWA/file +/// contexts where the native confirm prompt would feel out of place. async function confirmDeleteDNA() { - if (await window.showConfirmDialog('Delete genetic data? This cannot be undone.')) { - deleteGeneticsData(dnaWindow._getState().importedData); - await dnaWindow._saveAndRefresh(); + if (await confirmDnaDeleteDialog()) { + const runtimeState = getDnaRuntimeState(); + deleteGeneticsData((runtimeState || state).importedData); + await saveDnaRuntimeAndRefresh(); } } -initDnaActionDelegates({ triggerDNAFilePicker: () => window.triggerDNAFilePicker?.(), closeDNAImportPreview, closeMtDNAPreview, confirmDeleteDNA, confirmDNAImport, confirmMtDNAImport, deleteMtDNAData, importSnpReport, openManualSnpModal, reimportDNA, saveManualSnpFromModal, toggleGeneticsCollapse, toggleGeneticsExpand }); +initDnaActionDelegates({ triggerDNAFilePicker: triggerDnaFilePicker, closeDNAImportPreview, closeMtDNAPreview, confirmDeleteDNA, confirmDNAImport, confirmMtDNAImport, deleteMtDNAData, importSnpReport, openManualSnpModal, reimportDNA, saveManualSnpFromModal, toggleGeneticsCollapse, toggleGeneticsExpand }); -installDNAWindowBindings(window, { +publishDnaWindowBindings({ state, saveImportedData, buildGeneticsContext, getRelevantSNPs, isDNAFile, isDNAFileByContent, detectDNAFile, parseClinicalSnpReportText, parseManualSnpRows, upsertGeneticsSnp, handleDNAFile, handleSnpReportFile, importSnpReport, openManualSnpModal, saveManualSnpFromModal, closeDNAImportPreview, confirmDNAImport, confirmDeleteDNA, deleteGeneticsData, diff --git a/scripts/quality-baseline.json b/scripts/quality-baseline.json index ce60af0a..6aa08875 100644 --- a/scripts/quality-baseline.json +++ b/scripts/quality-baseline.json @@ -1,6 +1,6 @@ { "inlineEventAttributes": 0, - "windowReferences": 613, + "windowReferences": 589, "largeJsFilesOver800Lines": 29, "maxJsFileLines": 1513 } diff --git a/service-worker.js b/service-worker.js index 7cbbab46..71622510 100755 --- a/service-worker.js +++ b/service-worker.js @@ -256,6 +256,7 @@ const APP_SHELL = [ '/js/dna-actions.js', '/js/dna-genotype.js', '/js/dna.js', + '/js/dna-runtime.js', '/js/dna-window-bindings.js', '/js/dna-mtdna.js', '/js/hardware.js', diff --git a/tests/_vitest-legacy.test.js b/tests/_vitest-legacy.test.js index 53394b12..ca935453 100644 --- a/tests/_vitest-legacy.test.js +++ b/tests/_vitest-legacy.test.js @@ -217,6 +217,7 @@ const LEGACY_TESTS = [ // Blob-backed Worker; the synchronous Worker shim added to _node-shim.js // (+ _vitest-setup.js) runs self-contained pure-JS workers in-process. // renderGeneticsSection returns an HTML string with no DOM dependency. + './test-dna-runtime.js', './test-dna.js', './test-dna-illumina-and-valence.js', // Batch 36 — family-history (source-inspection + getConditionsSummary; diff --git a/tests/test-dna-runtime.js b/tests/test-dna-runtime.js new file mode 100644 index 00000000..26244a7a --- /dev/null +++ b/tests/test-dna-runtime.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +// test-dna-runtime.js — DNA runtime adapter behavior. + +import { + cacheDnaSnpTable, + callDnaFileHandler, + clearPendingDnaImport, + confirmDnaDeleteDialog, + getDnaRuntimeState, + getPendingDnaImport, + isDnaLabImportRunning, + logDnaDebugError, + logDnaDebugWarn, + navigateDnaRoute, + publishDnaWindowBindings, + refreshDnaShell, + refreshDnaSidebar, + saveDnaRuntimeAndRefresh, + setPendingDnaImport, + triggerDnaFilePicker, + updateDnaChatNudge, +} from '../js/dna-runtime.js'; + +let passed = 0; +let failed = 0; + +function assert(name, condition, detail = '') { + if (condition) { + passed++; + console.log(` PASS: ${name}`); + } else { + failed++; + console.log(` FAIL: ${name}${detail ? ` -- ${detail}` : ''}`); + } +} + +console.log('=== DNA Runtime Adapters ==='); + +const runtimeKeys = [ + '_buildGeneticsContext', + '_getRelevantSNPs', + '_getState', + '_pendingDNAImport', + '_saveAndRefresh', + '_snpTableCache', + 'buildSidebar', + 'handleDNAFile', + 'isDebugMode', + 'isImportRunning', + 'navigate', + 'showConfirmDialog', + 'triggerDNAFilePicker', + 'updateChatNudge', +]; +const originals = Object.fromEntries(runtimeKeys.map(key => [key, globalThis[key]])); +const originalError = console.error; +const originalWarn = console.warn; + +try { + for (const key of runtimeKeys) delete globalThis[key]; + + const calls = []; + globalThis.navigate = route => calls.push(['navigate', route]); + globalThis.buildSidebar = () => calls.push(['sidebar']); + refreshDnaShell('dashboard'); + assert('refreshDnaShell refreshes sidebar then navigates', + JSON.stringify(calls.slice(0, 2)) === JSON.stringify([['sidebar'], ['navigate', 'dashboard']])); + + calls.length = 0; + navigateDnaRoute('genome'); + assert('navigateDnaRoute delegates route changes', + calls.length === 1 && calls[0][0] === 'navigate' && calls[0][1] === 'genome'); + + globalThis.buildSidebar = () => { + throw new Error('sidebar failed'); + }; + let sidebarThrew = false; + try { refreshDnaSidebar(); } catch (_) { sidebarThrew = true; } + assert('refreshDnaSidebar swallows sidebar refresh errors', !sidebarThrew); + + globalThis.isImportRunning = () => true; + assert('isDnaLabImportRunning delegates true state', isDnaLabImportRunning() === true); + globalThis.isImportRunning = () => { + throw new Error('import status failed'); + }; + assert('isDnaLabImportRunning reports false on runtime errors', isDnaLabImportRunning() === false); + + globalThis.handleDNAFile = file => calls.push(['handleDNAFile', file.name]); + callDnaFileHandler({ name: 'dna.txt' }); + assert('callDnaFileHandler delegates file handling', + calls.some(call => call[0] === 'handleDNAFile' && call[1] === 'dna.txt')); + + globalThis.triggerDNAFilePicker = () => calls.push(['triggerDNAFilePicker']); + triggerDnaFilePicker(); + assert('triggerDnaFilePicker delegates picker opening', + calls.some(call => call[0] === 'triggerDNAFilePicker')); + + globalThis.updateChatNudge = () => calls.push(['updateChatNudge']); + updateDnaChatNudge(); + assert('updateDnaChatNudge delegates chat nudge refresh', + calls.some(call => call[0] === 'updateChatNudge')); + + globalThis.showConfirmDialog = async message => { + calls.push(['confirm', message]); + return true; + }; + assert('confirmDnaDeleteDialog delegates confirmation', + await confirmDnaDeleteDialog() === true && + calls.some(call => call[0] === 'confirm' && call[1].includes('Delete genetic data'))); + globalThis.showConfirmDialog = async () => { + throw new Error('dialog failed'); + }; + assert('confirmDnaDeleteDialog reports false on dialog errors', + await confirmDnaDeleteDialog() === false); + + const state = { importedData: { genetics: {} } }; + globalThis._getState = () => state; + assert('getDnaRuntimeState delegates state lookup', + getDnaRuntimeState() === state); + globalThis._saveAndRefresh = async () => calls.push(['saveAndRefresh']); + await saveDnaRuntimeAndRefresh(); + assert('saveDnaRuntimeAndRefresh delegates persistence', + calls.some(call => call[0] === 'saveAndRefresh')); + + const table = { rs1801133: { gene: 'MTHFR' } }; + cacheDnaSnpTable(table); + assert('cacheDnaSnpTable publishes SNP table cache', + globalThis._snpTableCache === table); + + setPendingDnaImport({ source: 'AncestryDNA' }); + assert('pending DNA import is published for browser flows', + getPendingDnaImport()?.source === 'AncestryDNA' && + globalThis._pendingDNAImport?.source === 'AncestryDNA'); + clearPendingDnaImport(); + assert('clearPendingDnaImport clears published pending import', + getPendingDnaImport() === null && globalThis._pendingDNAImport === null); + + let errorLogged = false; + let warnLogged = false; + console.error = () => { errorLogged = true; }; + console.warn = () => { warnLogged = true; }; + globalThis.isDebugMode = () => false; + logDnaDebugError('hidden'); + logDnaDebugWarn('hidden'); + assert('debug logs are gated off when debug mode is false', + !errorLogged && !warnLogged); + globalThis.isDebugMode = () => true; + logDnaDebugError('shown'); + logDnaDebugWarn('shown'); + assert('debug logs are emitted when debug mode is true', + errorLogged && warnLogged); + + publishDnaWindowBindings({ + state, + saveImportedData: async () => true, + buildGeneticsContext: () => '', + getRelevantSNPs: () => [], + handleDNAFile: () => {}, + }); + assert('publishDnaWindowBindings installs legacy exports', + typeof globalThis.handleDNAFile === 'function' && + globalThis._getState() === state && + typeof globalThis._saveAndRefresh === 'function'); +} finally { + console.error = originalError; + console.warn = originalWarn; + for (const key of runtimeKeys) { + if (originals[key] === undefined) delete globalThis[key]; + else globalThis[key] = originals[key]; + } +} + +console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total`); +if (failed > 0) process.exit(1); diff --git a/tests/test-dna.js b/tests/test-dna.js index ce8fc0e0..5f7854f6 100644 --- a/tests/test-dna.js +++ b/tests/test-dna.js @@ -483,7 +483,16 @@ assert('Genome lens header keeps affiliate link on its own row', stylesSrc.includes('flex-basis: 100%')); const dnaSrc = await fetchWithRetry('js/dna.js'); +const dnaRuntimeSrc = await fetchWithRetry('js/dna-runtime.js'); const dnaMtDnaSrc = await fetchWithRetry('js/dna-mtdna.js'); +assert('dna.js delegates browser runtime hooks to dna-runtime', + dnaSrc.includes("from './dna-runtime.js'") && + !/\bwindow\b/.test(dnaSrc)); +assert('dna-runtime owns DNA browser-global integration points', + dnaRuntimeSrc.includes('_pendingDNAImport') && + dnaRuntimeSrc.includes('_snpTableCache') && + dnaRuntimeSrc.includes("getRuntimeFunction('navigate')") && + dnaRuntimeSrc.includes('installDNAWindowBindings')); assert('genetics section collapses non-priority SNP calls', dnaSrc.includes('genetics-other-snps') && dnaSrc.includes('Other imported SNPs')); assert('genetics actions expose manual SNP and report import escape hatches', dnaSrc.includes("dnaActionAttrs('add-manual-snp')") && diff --git a/tests/test-quality-guardrails.js b/tests/test-quality-guardrails.js index a62cdb17..a4527491 100644 --- a/tests/test-quality-guardrails.js +++ b/tests/test-quality-guardrails.js @@ -82,6 +82,7 @@ const highValueCheckJsModules = [ 'js/client-list.js', 'js/dashboard-widget-renderers.js', 'js/dna.js', + 'js/dna-runtime.js', 'js/export.js', 'js/export-runtime.js', 'js/lens.js', diff --git a/tsconfig.checkjs.json b/tsconfig.checkjs.json index cc510760..ea511ae3 100644 --- a/tsconfig.checkjs.json +++ b/tsconfig.checkjs.json @@ -89,6 +89,7 @@ "js/data-merge.js", "js/data.js", "js/dna.js", + "js/dna-runtime.js", "js/dna-window-bindings.js", "js/emf.js", "js/export-report-builder.js", diff --git a/tsconfig.json b/tsconfig.json index 63ce0022..e876847b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -89,6 +89,7 @@ "js/data-merge.js", "js/data.js", "js/dna.js", + "js/dna-runtime.js", "js/dna-window-bindings.js", "js/emf.js", "js/emf-facade.js", diff --git a/version.js b/version.js index a5f7a8d3..85146181 100644 --- a/version.js +++ b/version.js @@ -2,4 +2,4 @@ // Classic script (not ES module) so it works in both browser and service worker. // Browser: