From dcecf5c34c9b897344786375ac0bbc6803c8baad Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 18:36:01 -0700 Subject: [PATCH] feat(ui): render rail cards and card blocks as components behind their builders The rail cards, card blocks, settings, chips and row lists are Vue components in src/ui/rail/. createRailCards() and createRailCardBlocks() keep their update and destroy API and patch the components synchronously with render(), so Weather and Recent imagery are unchanged. Controls of a removed block dispatch nothing, a released range keeps its value until the owner's next update, and range values are written only when they differ. Weather's remount state is keyed by its panel. Tests of rendered parts run on happy-dom. --- docs/VUE-MIGRATION.md | 7 +- scripts/package-boundaries.json | 29 +- src/testSupport/registerDom.mjs | 5 + src/ui/rail/RailCard.vue | 67 +++ src/ui/rail/RailCardBlocks.vue | 197 +++++++++ src/ui/rail/RailCardSetting.vue | 104 +++++ src/ui/rail/RailCards.vue | 24 + src/ui/rail/RailChips.vue | 28 ++ src/ui/rail/RailRowList.vue | 62 +++ src/ui/rail/rangeValue.js | 11 + src/ui/railCardBlocks.js | 413 +----------------- src/ui/railCards.js | 131 +----- src/ui/railCards.test.mjs | 78 ++-- src/ui/railTestFixture.mjs | 24 + src/ui/recentImagery.test.mjs | 252 +++-------- .../shellFeedbackStatusLocalization.test.mjs | 3 + src/ui/statusBadge.test.mjs | 13 +- src/ui/tileErrorI18n.test.mjs | 3 + src/ui/weatherPanel.js | 16 +- src/ui/weatherPanel.test.mjs | 53 ++- 20 files changed, 754 insertions(+), 766 deletions(-) create mode 100644 src/testSupport/registerDom.mjs create mode 100644 src/ui/rail/RailCard.vue create mode 100644 src/ui/rail/RailCardBlocks.vue create mode 100644 src/ui/rail/RailCardSetting.vue create mode 100644 src/ui/rail/RailCards.vue create mode 100644 src/ui/rail/RailChips.vue create mode 100644 src/ui/rail/RailRowList.vue create mode 100644 src/ui/rail/rangeValue.js diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 52067f981..1a7586052 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -76,11 +76,16 @@ They contain no application state. the store, so the owner keeps every decision. Static markup that the translation pass rewrites (`data-i18n*`) stays outside `v-if` and `v-for`, because re-rendering it would bring back the English; component - text uses `t()` instead. Order: Scenes (done: `SceneOptions` and + text uses `t()` instead. Shared builders become components first and + keep their imperative entry points until their owners are components: + the rail cards and card blocks (`src/ui/rail/`) render through Vue's + synchronous `render()` behind `createRailCards()` and + `createRailCardBlocks()`. Order: Scenes (done: `SceneOptions` and `SceneShotList` over the `gev-scenes` store), Weather, Recent imagery, Street level, CCTV, Layers, Display, Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. + 4. **Cleanup.** The build-time template expansion and the attribute translation pass are retired once nothing uses them. Hosts register panels and rail items through an explicit API instead of editing markup. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0abbb04e2..3ddef5cc7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -566,9 +566,16 @@ "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/ui/rail/RailChips.vue", + "src/ui/rail/RailRowList.vue", + "src/ui/rail/RailCardSetting.vue", + "src/ui/rail/RailCardBlocks.vue", + "src/ui/rail/RailCard.vue", + "src/ui/rail/RailCards.vue", + "src/ui/rail/rangeValue.js" ], - "external": [] + "external": ["vue"] }, "location-controls": { "exports": ["./ui/location"], @@ -924,7 +931,14 @@ "src/ui/kit/UiRail.vue", "src/ui/PanelRailView.vue", "src/ui/panelNavigation.js", - "src/ui/panelAccordion.js" + "src/ui/panelAccordion.js", + "src/ui/rail/RailRowList.vue", + "src/ui/rail/RailChips.vue", + "src/ui/rail/RailCardSetting.vue", + "src/ui/rail/RailCardBlocks.vue", + "src/ui/rail/RailCard.vue", + "src/ui/rail/RailCards.vue", + "src/ui/rail/rangeValue.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -2877,7 +2891,14 @@ "src/ui/kit/UiRail.vue", "src/ui/PanelRailView.vue", "src/ui/panelNavigation.js", - "src/ui/panelAccordion.js" + "src/ui/panelAccordion.js", + "src/ui/rail/RailChips.vue", + "src/ui/rail/RailCardSetting.vue", + "src/ui/rail/RailRowList.vue", + "src/ui/rail/RailCard.vue", + "src/ui/rail/RailCards.vue", + "src/ui/rail/RailCardBlocks.vue", + "src/ui/rail/rangeValue.js" ], "external": [ "hls.js", diff --git a/src/testSupport/registerDom.mjs b/src/testSupport/registerDom.mjs new file mode 100644 index 000000000..6b624d390 --- /dev/null +++ b/src/testSupport/registerDom.mjs @@ -0,0 +1,5 @@ +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +// Import first in a test file whose modules load Vue at import time: Vue binds +// the document when it loads. Each test file runs in its own process. +GlobalRegistrator.register({ url: 'http://localhost/' }); diff --git a/src/ui/rail/RailCard.vue b/src/ui/rail/RailCard.vue new file mode 100644 index 000000000..0881e4079 --- /dev/null +++ b/src/ui/rail/RailCard.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/ui/rail/RailCardBlocks.vue b/src/ui/rail/RailCardBlocks.vue new file mode 100644 index 000000000..f7e4bbe84 --- /dev/null +++ b/src/ui/rail/RailCardBlocks.vue @@ -0,0 +1,197 @@ + + + diff --git a/src/ui/rail/RailCardSetting.vue b/src/ui/rail/RailCardSetting.vue new file mode 100644 index 000000000..d26836537 --- /dev/null +++ b/src/ui/rail/RailCardSetting.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/ui/rail/RailCards.vue b/src/ui/rail/RailCards.vue new file mode 100644 index 000000000..d6fb4b3c9 --- /dev/null +++ b/src/ui/rail/RailCards.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/ui/rail/RailChips.vue b/src/ui/rail/RailChips.vue new file mode 100644 index 000000000..d912be6fa --- /dev/null +++ b/src/ui/rail/RailChips.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/ui/rail/RailRowList.vue b/src/ui/rail/RailRowList.vue new file mode 100644 index 000000000..059244207 --- /dev/null +++ b/src/ui/rail/RailRowList.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/ui/rail/rangeValue.js b/src/ui/rail/rangeValue.js new file mode 100644 index 000000000..b11744471 --- /dev/null +++ b/src/ui/rail/rangeValue.js @@ -0,0 +1,11 @@ +/** + * `v-range-value`: a range input's live value, written only when it differs. + * A `:value` binding would also rewrite the value attribute on every patch; + * the directive runs after the element's min, max and step are patched, so + * the value is never clamped to a stale range. + */ +const apply = (el, { value }) => { + const next = String(value); + if (el.value !== next) el.value = next; +}; +export const vRangeValue = { mounted: apply, updated: apply }; diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index b63d0a6a0..3a125759f 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -1,407 +1,22 @@ -import { t } from '../i18n/index.js'; -import { syncChipGroup } from './chipGroup.js'; -import { syncRowList } from './rowList.js'; -import { formatScaleLabel, unitLabel } from './unitLabels.js'; -import { createSlider } from './slider.js'; +import { h, render } from 'vue'; +import RailCardBlocks from './rail/RailCardBlocks.vue'; -const set = (node, key, value) => { - if (node[key] !== value) node[key] = value; -}; -const attribute = (node, key, value) => { - if (node.getAttribute(key) !== value) node.setAttribute(key, value); -}; -const order = (parent, nodes) => - nodes.forEach((node, i) => { - if (parent.children[i] !== node) - parent.insertBefore(node, parent.children[i] || null); - }); - -// "960 — 1050+ гПа": the unit stays with the last number. -const scaleText = (low, high, units) => { - const unit = unitLabel(units); - const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`; - return unit ? `${range}\u00a0${unit}` : range; -}; - -/** Optional, ordered blocks without disclosure state. All controls are keyed. */ +/** + * Optional, ordered blocks without disclosure state. All controls are keyed. + * Renders the RailCardBlocks component into `container`, which it owns, and + * patches it synchronously on every update. + */ export function createRailCardBlocks({ container, cardId, onParams }) { - const document = container.ownerDocument; - const blocks = new Map(); - const make = (tag, className, parent) => { - const node = document.createElement(tag); - node.className = className; - parent.appendChild(node); - return node; - }; - const dispatch = (props) => { - if (!props || props.disabled) return; - if (props.params) onParams(cardId, props.params); - else props.onClick?.(); - }; - const dispose = (block) => { - block.removeClick?.(); - block.removeInput?.(); - block.children?.destroy(); - block.root.remove(); - }; + let destroyed = false; return { - update(descriptors = []) { - const ids = new Set(); - for (const props of descriptors) { - ids.add(props.id); - let block = blocks.get(props.id); - if (block && block.type !== props.type) { - dispose(block); - blocks.delete(props.id); - block = null; - } - if (!block) { - const root = make( - props.type === 'list' - ? 'ol' - : props.type === 'actions' - ? 'footer' - : 'div', - props.type === 'list' ? 'data-row-list' : `rail-card-${props.type}`, - container, - ); - root.dataset.blockId = props.id; - block = { root, type: props.type, props }; - const bind = (fn) => { - root.addEventListener('click', fn); - block.removeClick = () => root.removeEventListener('click', fn); - }; - if (props.type === 'settings') { - block.children = createRailCardBlocks({ - container: root, - cardId, - onParams, - }); - } else if (props.type === 'setting') { - block.label = make( - 'span', - 'rail-card-setting-label panel-title', - root, - ); - block.chips = make('div', 'rail-card-chips', root); - block.chips.setAttribute('role', 'group'); - block.rangeRow = make('div', 'rail-card-setting-range-row', root); - block.rangeLabel = make( - 'span', - 'rail-card-setting-range-label', - block.rangeRow, - ); - block.rangeInput = createSlider({ - documentRef: document, - className: 'rail-card-setting-range', - }); - block.rangeRow.appendChild(block.rangeInput); - block.rangeValue = make( - 'span', - 'rail-card-setting-range-value', - block.rangeRow, - ); - const rangeParams = () => { - const range = block.props.range; - const value = Number(block.rangeInput.value); - if ( - !range?.param || - range.disabled || - !Number.isFinite(value) || - value < Number(range.min) || - value > Number(range.max) - ) - return null; - return { params: { [range.param]: value } }; - }; - const onInput = () => { - const range = block.props.range; - const value = Number(block.rangeInput.value); - const params = rangeParams(); - if (!params) return; - if (range.commitOnChange) block.rangeDraft = { range, value }; - set( - block.rangeValue, - 'textContent', - `${value}${range.unit || ''}`, - ); - attribute( - block.rangeInput, - 'aria-valuetext', - `${value}${range.unit || ''}`, - ); - if (!range.commitOnChange) dispatch(params); - }; - const onChange = () => { - if (!block.props.range?.commitOnChange) return; - const params = rangeParams(); - block.rangeDraft = null; - if (params) dispatch(params); - }; - block.rangeInput.addEventListener('input', onInput); - block.rangeInput.addEventListener('change', onChange); - block.removeInput = () => { - block.rangeInput.removeEventListener('input', onInput); - block.rangeInput.removeEventListener('change', onChange); - }; - bind((event) => { - const button = event.target?.closest?.('[data-chip-id]'); - dispatch( - block.props.chips?.find( - ({ id }) => id === button?.dataset.chipId, - ), - ); - }); - } else if (props.type === 'list') { - bind((event) => { - const button = event.target?.closest?.('.data-row-list-item'); - dispatch( - block.props.list?.items?.find( - ({ id }) => id === button?.dataset.listItemId, - ), - ); - }); - } else if (props.type === 'result') { - block.header = make('div', 'rail-card-result-header', root); - block.label = make('span', 'panel-title', block.header); - block.clear = make('button', 'data-toggle-chip', block.header); - block.clear.type = 'button'; - block.clear.dataset.actionId = 'clear'; - block.clear.textContent = '×'; - block.clear.setAttribute('aria-label', t('Clear reading')); - block.clear.title = t('Clear reading'); - bind((event) => { - if (event.target === block.clear) dispatch(block.props.clear); - }); - block.children = createRailCardBlocks({ - container: make('div', 'rail-card-result-lines', root), - cardId, - onParams, - }); - } else if (props.type === 'legend') { - block.ramp = make('div', 'rail-card-ramp', root); - block.zero = make('span', 'rail-card-zero', block.ramp); - block.zero.setAttribute('aria-hidden', 'true'); - block.scale = make('div', 'rail-card-scale', root); - block.entries = make('div', 'rail-card-legend-entries', root); - } else if (props.type === 'actions') { - block.actions = new Map(); - bind((event) => { - for (const action of block.actions.values()) - if (event.target === action.node) dispatch(action.props); - }); - } else block.lines = new Map(); - blocks.set(props.id, block); - } - block.props = props; - if (props.type === 'settings') { - block.children.update( - (props.settings || []).map((setting) => ({ - ...setting, - type: 'setting', - })), - ); - } else if (props.type === 'setting') { - set(block.label, 'textContent', props.label); - attribute(block.chips, 'aria-label', props.label); - syncChipGroup(block.chips, props.chips); - const range = props.range; - set(block.rangeRow, 'hidden', !range); - const draft = block.rangeDraft; - if ( - draft && - (!range || - range.disabled || - !range.commitOnChange || - ['id', 'param', 'min', 'max', 'step', 'value'].some( - (key) => range[key] !== draft.range[key], - )) - ) - block.rangeDraft = null; - if (range) { - const min = Number(range.min); - const max = Number(range.max); - const step = Number(range.step); - const candidate = Number(block.rangeDraft?.value ?? range.value); - const value = Math.min( - max, - Math.max(min, Number.isFinite(candidate) ? candidate : min), - ); - set(block.rangeLabel, 'textContent', range.label || ''); - set(block.rangeLabel, 'hidden', !range.label); - set(block.rangeInput.dataset, 'rangeId', range.id || props.id); - set(block.rangeInput, 'min', String(min)); - set(block.rangeInput, 'max', String(max)); - set(block.rangeInput, 'step', String(step)); - set(block.rangeInput, 'value', String(value)); - set(block.rangeInput, 'disabled', Boolean(range.disabled)); - attribute( - block.rangeInput, - 'aria-label', - range.ariaLabel || props.label, - ); - attribute( - block.rangeInput, - 'aria-valuetext', - `${value}${range.unit || ''}`, - ); - set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`); - } - } else if (props.type === 'list') syncRowList(block.root, props.list); - else if (props.type === 'result') { - set(block.label, 'textContent', props.label); - set(block.clear, 'hidden', !props.clear); - block.children.update([ - { id: 'result-lines', type: 'lines', lines: props.lines }, - ]); - } else if (props.type === 'legend') { - const legend = props.legend; - const colors = (legend?.colors || []).filter((color) => - /^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color), - ); - const visible = - colors.length >= 2 && colors.length === legend.colors.length; - set(block.root, 'hidden', !visible); - set(block.ramp, 'hidden', !visible || Boolean(legend?.categorical)); - set(block.scale, 'hidden', Boolean(legend?.categorical)); - set(block.entries, 'hidden', !legend?.categorical); - if (legend?.categorical) { - while (block.entries.children.length > colors.length) - block.entries.children[ - block.entries.children.length - 1 - ].remove(); - colors.forEach((color, index) => { - let entry = block.entries.children[index]; - if (!entry) { - entry = make('div', 'rail-card-legend-entry', block.entries); - make('span', 'rail-card-legend-swatch', entry); - make('span', '', entry); - } - if (entry.dataset.color !== color) { - entry.children[0].style.background = color; - entry.dataset.color = color; - } - set(entry.children[1], 'textContent', legend.labels[index] || ''); - }); - } - const gradient = visible - ? `linear-gradient(to right, ${colors.join(',')})` - : ''; - if (block.gradient !== gradient) { - block.ramp.style.background = gradient; - block.gradient = gradient; - } - const zeroVisible = - visible && - legend.units === '°C' && - Number.isInteger(legend.zeroIndex) && - legend.zeroIndex >= 0 && - legend.zeroIndex < colors.length; - set(block.zero, 'hidden', !zeroVisible); - if (zeroVisible) { - const left = `${(legend.zeroIndex / (colors.length - 1)) * 100}%`; - if (block.zeroLeft !== left) { - block.zero.style.left = left; - block.zeroLeft = left; - } - } - set( - block.scale, - 'textContent', - visible - ? scaleText(legend.labels[0], legend.labels.at(-1), legend.units) - : '', - ); - } else if (props.type === 'actions') { - const actions = props.actions || []; - for (const [id, action] of block.actions) - if ( - !actions.some( - (props) => - props.id === id && - (props.href ? 'a' : 'button') === action.tag, - ) - ) { - action.node.remove(); - action.hint.remove(); - block.actions.delete(id); - } - for (const actionProps of actions) { - let action = block.actions.get(actionProps.id); - if (!action) { - const tag = actionProps.href ? 'a' : 'button'; - const node = make(tag, 'data-toggle-chip', block.root); - node.dataset.actionId = actionProps.id; - if (tag === 'button') node.type = 'button'; - else { - node.target = '_blank'; - node.rel = 'noopener'; - } - action = { - node, - tag, - hint: make('span', 'rail-card-action-hint', block.root), - }; - block.actions.set(actionProps.id, action); - } - action.props = actionProps; - set(action.node, 'textContent', actionProps.label); - set(action.node, 'title', actionProps.title || ''); - set(action.node, 'disabled', Boolean(actionProps.disabled)); - if (action.tag === 'a' && action.href !== actionProps.href) { - action.node.href = actionProps.href; - action.href = actionProps.href; - } - set(action.hint, 'textContent', actionProps.hint || ''); - set(action.hint, 'hidden', !actionProps.hint); - } - order( - block.root, - actions.flatMap(({ id }) => { - const action = block.actions.get(id); - return [action.node, action.hint]; - }), - ); - } else { - const lines = props.lines || [{ id: props.id, text: props.text }]; - for (const [id, node] of block.lines) - if (!lines.some((line) => line.id === id)) { - node.remove(); - block.lines.delete(id); - } - for (const line of lines) { - let node = block.lines.get(line.id); - if (!node) { - node = make('div', 'rail-card-line', block.root); - node.dataset.lineId = line.id; - block.lines.set(line.id, node); - } - set(node, 'textContent', String(line.text ?? '')); - set( - node, - 'className', - `rail-card-line${line.muted ? ' muted' : ''}`, - ); - } - order( - block.root, - lines.map(({ id }) => block.lines.get(id)), - ); - } - } - for (const [id, block] of blocks) - if (!ids.has(id)) { - dispose(block); - blocks.delete(id); - } - order( - container, - descriptors.map(({ id }) => blocks.get(id).root), - ); + update(blocks = []) { + if (destroyed) return; + render(h(RailCardBlocks, { blocks, cardId, onParams }), container); }, destroy() { - for (const block of blocks.values()) dispose(block); - blocks.clear(); + if (destroyed) return; + destroyed = true; + render(null, container); }, }; } diff --git a/src/ui/railCards.js b/src/ui/railCards.js index 7317693be..7972b6790 100644 --- a/src/ui/railCards.js +++ b/src/ui/railCards.js @@ -1,12 +1,11 @@ -import { createRailCardBlocks } from './railCardBlocks.js'; -import { createStatusBadge, updateStatusBadge } from './statusBadge.js'; +import { h, render } from 'vue'; +import RailCards from './rail/RailCards.vue'; -const set = (node, key, value) => { - if (node[key] !== value) node[key] = value; -}; -let nextId = 0; - -/** Keyed cards with optional ordered blocks; the feature owns open-card choice. */ +/** + * Keyed cards with optional ordered blocks; the feature owns open-card choice. + * Renders the RailCards component into `container`, which it owns, and + * patches it synchronously on every update so the owner can measure at once. + */ export function createRailCards({ container, document = container?.ownerDocument, @@ -16,117 +15,25 @@ export function createRailCards({ onOpen = () => {}, } = {}) { if (!document?.createElement || !container) return null; - const rows = new Map(); let destroyed = false; - const make = (tag, className, parent) => { - const node = document.createElement(tag); - node.className = className; - parent.appendChild(node); - return node; - }; - const dispose = (row) => { - row.header.removeEventListener('click', row.click); - row.blocks.destroy(); - row.element.remove(); - }; return { update(cards = []) { if (destroyed) return; - const active = new Set(); - for (const card of cards) { - active.add(card.id); - let row = rows.get(card.id); - if (!row) { - const element = make( - 'article', - `rail-card${cardClassName ? ` ${cardClassName}` : ''}`, - container, - ); - element.dataset.cardId = card.id; - const header = make('button', 'rail-card-header', element); - header.type = 'button'; - const labels = make('span', 'rail-card-labels', header); - const heading = make('span', 'rail-card-heading', labels); - const icon = make('span', 'data-icon', heading); - icon.setAttribute('aria-hidden', 'true'); - const title = make( - 'span', - 'rail-card-title rail-card-nowrap', - heading, - ); - const badge = createStatusBadge({ - documentRef: document, - appearance: 'plain', - className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`, - }); - labels.appendChild(badge); - const disclosure = make('span', 'rail-card-disclosure', header); - disclosure.setAttribute('aria-hidden', 'true'); - const compact = make('span', 'rail-card-compact', header); - const body = make('div', 'rail-card-body', element); - body.id = `rail-card-body-${++nextId}`; - header.setAttribute('aria-controls', body.id); - const blocks = createRailCardBlocks({ - container: body, - cardId: card.id, - onParams, - }); - const click = () => onOpen(card.id); - header.addEventListener('click', click); - row = { - element, - header, - icon, - title, - disclosure, - badge, - compact, - body, - blocks, - click, - }; - rows.set(card.id, row); - } - set(row.title, 'textContent', card.title); - if (row.title.getAttribute('title') !== card.title) - row.title.setAttribute('title', card.title); - set(row.icon, 'textContent', card.icon || ''); - set(row.icon, 'hidden', !card.icon); - updateStatusBadge(row.badge, { - label: card.badge || '', - hidden: !card.badge, - }); - const open = card.open !== false; - set(row.element.dataset, 'open', String(open)); - row.element.classList.toggle('is-open', open); - set(row.disclosure, 'textContent', open ? '▾' : '▸'); - if (row.header.getAttribute('aria-expanded') !== String(open)) - row.header.setAttribute('aria-expanded', String(open)); - set(row.body, 'hidden', !open); - set(row.compact, 'hidden', open); - set(row.compact, 'textContent', card.compact || ''); - set( - row.compact, - 'className', - `rail-card-compact${card.compactStatus ? ' status' : ''}`, - ); - row.blocks.update(card.blocks || []); - } - for (const [id, row] of rows) - if (!active.has(id)) { - dispose(row); - rows.delete(id); - } - cards.forEach(({ id }, index) => { - const node = rows.get(id).element; - if (container.children[index] !== node) - container.insertBefore(node, container.children[index] || null); - }); + render( + h(RailCards, { + cards, + cardClassName, + badgeClassName, + onParams, + onOpen, + }), + container, + ); }, destroy() { + if (destroyed) return; destroyed = true; - for (const row of rows.values()) dispose(row); - rows.clear(); + render(null, container); }, }; } diff --git a/src/ui/railCards.test.mjs b/src/ui/railCards.test.mjs index 74629bebb..7107fd093 100644 --- a/src/ui/railCards.test.mjs +++ b/src/ui/railCards.test.mjs @@ -1,7 +1,9 @@ +import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { nextTick } from 'vue'; import { createRailCards } from './railCards.js'; -import { railFixture } from './railTestFixture.mjs'; +import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; const legend = { colors: ['#112233', '#ffffff', '#abcdef'], @@ -25,8 +27,7 @@ const card = { ], }; test('keyed cards and optional blocks reorder without redundant writes', () => { - let writes = 0; - const f = railFixture(() => writes++); + const f = railFixture(); const view = createRailCards(f); view.update([card]); const article = f.container.children[0]; @@ -50,13 +51,15 @@ test('keyed cards and optional blocks reorder without redundant writes', () => { [], ]) { view.update(cards); - writes = 0; + f.writes(); view.update(cards); - assert.equal(writes, 0); + assert.equal(f.writes(), 0); } view.update([{ ...card, blocks: [...card.blocks].reverse() }]); assert.deepEqual( - f.container.children[0].children[1].children.map((n) => n.dataset.blockId), + [...f.container.children[0].children[1].children].map( + (n) => n.dataset.blockId, + ), ['actions', 'legend', 'details'], ); view.destroy(); @@ -92,7 +95,7 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs ]), ]); assert.equal(f.document.activeElement, button); - assert.equal(button.parent.tagName, 'FOOTER'); + assert.equal(button.parentElement.tagName, 'FOOTER'); assert.equal( f.find((n) => n.className === 'rail-card-action-hint').textContent, 'at map center', @@ -119,7 +122,7 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs button.click(); assert.deepEqual(calls, [2]); }); -test('setting ranges stay below presets and commit expensive filters on release', () => { +test('setting ranges stay below presets and commit expensive filters on release', async () => { const f = railFixture(); const calls = []; const view = createRailCards({ @@ -177,6 +180,7 @@ test('setting ranges stay below presets and commit expensive filters on release' slider.focus(); slider.value = '53'; slider.dispatchEvent(new Event('input')); + await nextTick(); assert.equal(calls.length, 0, 'expensive image work waits until release'); assert.equal( f.find((node) => node.className === 'rail-card-setting-range-value') @@ -192,11 +196,14 @@ test('setting ranges stay below presets and commit expensive filters on release' ); slider.dispatchEvent(new Event('change')); assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]); + await nextTick(); + assert.equal(slider.value, '53', 'release keeps the handle until the owner'); view.update([model(53)]); assert.equal(slider.value, '53'); assert.equal(f.document.activeElement, slider); slider.value = '41'; slider.dispatchEvent(new Event('input')); + await nextTick(); view.update([model(70)]); assert.equal( slider.value, @@ -208,10 +215,31 @@ test('setting ranges stay below presets and commit expensive filters on release' slider.dispatchEvent(new Event('change')); assert.equal(calls.length, 1, 'destroy removes the range listener'); }); +test('controls of a removed block dispatch nothing', () => { + const f = railFixture(); + const calls = []; + const view = createRailCards({ + ...f, + onParams: (...args) => calls.push(args), + }); + const actions = { + id: 'actions', + type: 'actions', + actions: [{ id: 'go', label: 'Go', params: { go: 1 } }], + }; + view.update([{ ...card, blocks: [actions] }]); + const button = f.find((node) => node.dataset.actionId === 'go'); + view.update([{ ...card, blocks: [] }]); + button.click(); + assert.deepEqual(calls, []); + view.update([{ ...card, blocks: [{ ...actions }] }]); + f.find((node) => node.dataset.actionId === 'go').click(); + assert.deepEqual(calls, [['a', { go: 1 }]]); + view.destroy(); +}); test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => { - let writes = 0; - const f = railFixture(() => writes++); + const f = railFixture(); const view = createRailCards(f); const show = (legend) => view.update([ @@ -222,26 +250,11 @@ test('legend validates colors, preserves the freezing anchor and ignores CSSOM n const ramp = f.find((n) => n.className === 'rail-card-ramp'); assert.equal(zero.hidden, false); assert.equal(zero.style.left, '50%'); - Object.defineProperty(ramp.style, 'background', { - get: () => - 'linear-gradient(to right, rgb(17, 34, 51), rgb(255, 255, 255), rgb(171, 205, 239))', - set() { - writes++; - }, - configurable: true, - }); - Object.defineProperty(zero.style, 'left', { - get: () => '50.0%', - set() { - writes++; - }, - configurable: true, - }); - writes = 0; + // An unchanged legend writes nothing, whatever the browser normalizes the + // colors it reads back to. + f.writes(); show(legend); - assert.equal(writes, 0); - delete ramp.style.background; - delete zero.style.left; + assert.equal(f.writes(), 0); show({ ...legend, colors: Array(10).fill('#abcdef'), @@ -257,8 +270,7 @@ test('legend validates colors, preserves the freezing anchor and ignores CSSOM n view.destroy(); }); test('labelled rows, lists and results retain focus and dispatch current params', () => { - let writes = 0; - const f = railFixture(() => writes++); + const f = railFixture(); const calls = []; const view = createRailCards({ ...f, @@ -337,9 +349,9 @@ test('labelled rows, lists and results retain focus and dispatch current params' clear.click(); assert.deepEqual(calls.at(-1), ['a', { inspect: false }]); assert.equal(f.find((n) => n.dataset.lineId === 'wind').children.length, 0); - writes = 0; + f.writes(); view.update([model(true)]); - assert.equal(writes, 0); + assert.equal(f.writes(), 0); view.destroy(); const before = calls.length; chip.click(); diff --git a/src/ui/railTestFixture.mjs b/src/ui/railTestFixture.mjs index 23b4d7528..8d71377c6 100644 --- a/src/ui/railTestFixture.mjs +++ b/src/ui/railTestFixture.mjs @@ -114,3 +114,27 @@ export function railFixture(onWrite = () => {}) { find: (predicate, root = container) => find(root, predicate), }; } + +/** + * A container in the test document for presentation that renders with Vue, + * with a count of DOM writes. Import `../testSupport/registerDom.mjs` first. + */ +export function railDocumentFixture() { + const container = document.createElement('div'); + document.body.appendChild(container); + const observer = new MutationObserver(() => {}); + observer.observe(container, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + return { + container, + document, + find: (predicate, root = container) => + [root, ...root.querySelectorAll('*')].find(predicate) ?? null, + /** DOM writes since the last call. */ + writes: () => observer.takeRecords().length, + }; +} diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index cf1a57781..a5c9db284 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -1,3 +1,4 @@ +import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -18,186 +19,73 @@ const S18 = 'S30:2026-09-18'; const L16 = 'L30:2026-09-16'; const V21 = 'VIIRS:2026-09-21'; -// ---- a small DOM double ------------------------------------------------------ -class FakeClassList { - constructor(node) { - this.node = node; - } - get names() { - return new Set( - String(this.node.className || '') - .split(/\s+/) - .filter(Boolean), - ); - } - contains(name) { - return this.names.has(name); - } - toggle(name, force) { - const names = this.names; - const on = force === undefined ? !names.has(name) : Boolean(force); - if (on) names.add(name); - else names.delete(name); - this.node.className = [...names].join(' '); - return on; - } - add(...list) { - for (const name of list) this.toggle(name, true); - } - remove(...list) { - for (const name of list) this.toggle(name, false); - } -} - -function matches(node, selector) { - if (selector.startsWith('.')) - return node.classList.contains(selector.slice(1)); - if (selector.startsWith('#')) return node.id === selector.slice(1); - const attr = /^\[([\w-]+)(?:="([^"]*)")?\]$/.exec(selector); - if (!attr) return false; - const key = attr[1].startsWith('data-') - ? attr[1].slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) - : null; - const value = key ? node.dataset[key] : node.getAttribute(attr[1]); - return attr[2] === undefined ? value != null : value === attr[2]; -} - -class FakeElement { - constructor(tagName, ownerDocument) { - this.tagName = String(tagName).toUpperCase(); - this.ownerDocument = ownerDocument; - this.children = []; - this.parentNode = null; - this.className = ''; - this.dataset = {}; - this.style = {}; - this.attributes = new Map(); - this.listeners = new Map(); - this.hidden = false; - this.disabled = false; - this.id = ''; - this.title = ''; - this._text = ''; - this.scrollTop = 0; - this.scrollLeft = 0; - this.focused = 0; - this.blurred = 0; - } - get classList() { - return new FakeClassList(this); - } - get textContent() { - return this.children.length - ? this.children.map((child) => child.textContent).join('') - : this._text; - } - set textContent(value) { - for (const child of this.children) child.parentNode = null; - this.children = []; - this._text = String(value); - } - setAttribute(name, value) { - this.attributes.set(name, String(value)); - } - getAttribute(name) { - return this.attributes.get(name) ?? null; - } - removeAttribute(name) { - this.attributes.delete(name); - } - appendChild(child) { - return this.insertBefore(child, null); - } - insertBefore(child, reference) { - child.parentNode?.removeChild(child); - const index = reference ? this.children.indexOf(reference) : -1; - if (index < 0) this.children.push(child); - else this.children.splice(index, 0, child); - child.parentNode = this; - return child; - } - append(...children) { - for (const child of children) this.appendChild(child); - } - removeChild(child) { - this.children.splice(this.children.indexOf(child), 1); - child.parentNode = null; - } - remove() { - this.parentNode?.removeChild(this); - } - addEventListener(type, handler) { - if (!this.listeners.has(type)) this.listeners.set(type, new Set()); - this.listeners.get(type).add(handler); - } - removeEventListener(type, handler) { - this.listeners.get(type)?.delete(handler); - } - /** Bubble an event from this node to the root. */ - dispatch(type, init = {}) { - const event = { - type, - target: this, - defaultPrevented: false, - preventDefault() { - this.defaultPrevented = true; - }, - ...init, - }; - for (let node = this; node; node = node.parentNode) - for (const handler of [...(node.listeners.get(type) || [])]) - handler(event); - return event; - } - click() { - if (!this.disabled) this.dispatch('click'); - } - matches(selector) { - return matches(this, selector); - } - closest(selector) { - for (let node = this; node; node = node.parentNode) - if (matches(node, selector)) return node; - return null; - } - querySelector(selector) { - return this.find((node) => matches(node, selector)); - } - find(predicate) { - for (const child of this.children) { - if (predicate(child)) return child; - const inner = child.find(predicate); - if (inner) return inner; - } - return null; - } - findAll(predicate, out = []) { - for (const child of this.children) { - if (predicate(child)) out.push(child); - child.findAll(predicate, out); - } - return out; - } - focus(options) { - this.focused += 1; - this.focusOptions = options; - } - blur() { - this.blurred += 1; - } +// ---- the test document ------------------------------------------------------ +// The readout renders its cards with Vue, so it runs in a real document. These +// test-only conveniences count focus, blur and listeners per element. +const elementProto = globalThis.HTMLElement.prototype; +const { focus: nativeFocus, blur: nativeBlur } = elementProto; +elementProto.focus = function (options) { + this.focused = (this.focused || 0) + 1; + this.focusOptions = options; + return nativeFocus.call(this, options); +}; +elementProto.blur = function () { + this.blurred = (this.blurred || 0) + 1; + return nativeBlur.call(this); +}; +const listeners = new WeakMap(); +const { addEventListener: nativeAdd, removeEventListener: nativeRemove } = + globalThis.Element.prototype; +globalThis.Element.prototype.addEventListener = function ( + type, + handler, + ...rest +) { + if (!listeners.has(this)) listeners.set(this, new Map()); + const byType = listeners.get(this); + if (!byType.has(type)) byType.set(type, new Set()); + byType.get(type).add(handler); + return nativeAdd.call(this, type, handler, ...rest); +}; +globalThis.Element.prototype.removeEventListener = function ( + type, + handler, + ...rest +) { + listeners.get(this)?.get(type)?.delete(handler); + return nativeRemove.call(this, type, handler, ...rest); +}; +Object.assign(globalThis.Element.prototype, { listenerCount() { - return [...this.listeners.values()].reduce((n, set) => n + set.size, 0); - } -} + return [...(listeners.get(this)?.values() ?? [])].reduce( + (n, set) => n + set.size, + 0, + ); + }, + /** The first descendant matching `predicate`, in document order. */ + find(predicate) { + return [...this.querySelectorAll('*')].find(predicate) ?? null; + }, + findAll(predicate) { + return [...this.querySelectorAll('*')].filter(predicate); + }, + /** Bubble an event of `type` carrying `init`'s fields; returns the event. */ + dispatch(type, init = {}) { + const event = new Event(type, { bubbles: true, cancelable: true }); + for (const [key, value] of Object.entries(init)) + Object.defineProperty(event, key, { value }); + this.dispatchEvent(event); + return event; + }, +}); /** The static rail panel skeleton from views/ContextView.vue. */ function fakeDocument() { - const document = { - documentElement: { clientWidth: 1200 }, - createElement: (tag) => new FakeElement(tag, document), - getElementById: (id) => document.body.find((node) => node.id === id), - }; - document.body = new FakeElement('body', document); + document.body.textContent = ''; + Object.defineProperty(document.documentElement, 'clientWidth', { + configurable: true, + value: 1200, + }); const panel = document.createElement('div'); panel.id = 'recent-imagery-panel'; panel.className = 'panel-collapsible collapsed'; @@ -386,7 +274,7 @@ test('the readout mounts in the rail body, hides while the layer is off and open test('the body is a fixed stack of seven blocks in a fixed order, each with a fixed height in the stylesheet', () => { const f = fixture(); assert.deepEqual( - f.root.children.map((node) => node.id || node.className), + [...f.root.children].map((node) => node.id || node.className), [ 'ri-actions', 'ri-notice', @@ -520,7 +408,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h /height: 22px/, ], 'details header': [ - f.root.children.at(-1) === f.byId('ri-details') + [...f.root.children].at(-1) === f.byId('ri-details') ? header?.find((node) => node.classList.contains('rail-card-title')) : null, 'rail-card-nowrap', @@ -582,7 +470,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h const path = []; for (let at = node; at && at !== f.root; at = at.parentNode) { assert.equal(at.hidden, false, `${name} is never hidden`); - path.unshift(`${at.tagName}${at.parentNode.children.indexOf(at)}`); + path.unshift(`${at.tagName}${[...at.parentNode.children].indexOf(at)}`); } out[name] = path.join('/'); } @@ -704,7 +592,7 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch ], ['A', 'B', false, false], ); - assert.equal(a.parentNode.children.indexOf(a), 0); + assert.equal([...a.parentNode.children].indexOf(a), 0); assert.equal(b.getAttribute('aria-label'), 'Pin Sep 18 as B'); // A day is one slot only: B moves it. b.click(); @@ -923,7 +811,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l const zoom = f.byId('ri-zoom-in'); assert.equal(zoom.tagName, 'BUTTON'); assert.equal(zoom.textContent, 'ZOOM IN'); - assert.equal(notice.children.at(-1), zoom, 'the right end of the line'); + assert.equal([...notice.children].at(-1), zoom, 'the right end of the line'); const reserved = () => [ zoom.classList.contains('is-reserved'), zoom.disabled, @@ -1152,7 +1040,7 @@ test('opening DETAILS scrolls its card into view, and closing it does not scroll await f.ready(); // The body is 100 px high and scrolled 40 px; the card opens 150 px down. f.body.scrollTop = 40; - f.body.clientHeight = 100; + Object.defineProperty(f.body, 'clientHeight', { value: 100 }); f.body.getBoundingClientRect = () => ({ top: 0 }); const header = f .byId('ri-details') diff --git a/src/ui/shellFeedbackStatusLocalization.test.mjs b/src/ui/shellFeedbackStatusLocalization.test.mjs index 9639fe834..b0e931a65 100644 --- a/src/ui/shellFeedbackStatusLocalization.test.mjs +++ b/src/ui/shellFeedbackStatusLocalization.test.mjs @@ -18,6 +18,9 @@ globalThis.document ??= { getElementById: (id) => elements[id] ?? null, addEventListener() {}, removeEventListener() {}, + // The layer panel reaches Vue, whose DOM runtime creates one element as it + // loads. + createElement: () => ({}), hidden: false, }; const { ShellFeedback, localizeLoadingPresentation } = diff --git a/src/ui/statusBadge.test.mjs b/src/ui/statusBadge.test.mjs index 2e576af69..1ac67a385 100644 --- a/src/ui/statusBadge.test.mjs +++ b/src/ui/statusBadge.test.mjs @@ -1,8 +1,9 @@ +import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createStatusBadge, updateStatusBadge } from './statusBadge.js'; import { createRailCards } from './railCards.js'; -import { railFixture } from './railTestFixture.mjs'; +import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; test('badges paint literal translated text without installing announcement or interaction semantics', () => { const { document } = railFixture(); @@ -32,19 +33,21 @@ test('badges paint literal translated text without installing announcement or in }); test('painting preserves feature state, live regions and focus with zero identical writes', () => { - let writes = 0; - const { document } = railFixture(() => writes++); + const f = railFixture(); + const { document } = f; const badge = createStatusBadge({ documentRef: document, hidden: true }); + f.container.appendChild(badge); badge.setAttribute('role', 'status'); badge.setAttribute('aria-live', 'polite'); badge.dataset.frameState = 'ready'; const focusOwner = document.createElement('button'); + document.body.appendChild(focusOwner); focusOwner.focus(); updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' }); assert.equal(badge.hidden, false); - writes = 0; + f.writes(); updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' }); - assert.equal(writes, 0); + assert.equal(f.writes(), 0); assert.equal(badge.getAttribute('role'), 'status'); assert.equal(badge.getAttribute('aria-live'), 'polite'); assert.equal(badge.dataset.frameState, 'ready'); diff --git a/src/ui/tileErrorI18n.test.mjs b/src/ui/tileErrorI18n.test.mjs index 829c55e48..d2a373b46 100644 --- a/src/ui/tileErrorI18n.test.mjs +++ b/src/ui/tileErrorI18n.test.mjs @@ -9,6 +9,9 @@ globalThis.document ??= { getElementById: () => null, addEventListener() {}, removeEventListener() {}, + // The layer panel reaches Vue, whose DOM runtime creates one element as it + // loads. + createElement: () => ({}), hidden: false, }; const { LayerPanel } = await import('./layerPanel.js'); diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js index 24ff83fd7..e37911a7c 100644 --- a/src/ui/weatherPanel.js +++ b/src/ui/weatherPanel.js @@ -3,8 +3,9 @@ import { createRailCards } from './railCards.js'; import { createRailTimeline, utcClock } from './railTimeline.js'; // Remounting controls must not reopen a panel the user already collapsed. -const appearedDocuments = new WeakSet(); -const openDocuments = new WeakMap(); +// Keyed by the page's Weather panel, which outlives remounted controls. +const appearedPanels = new WeakSet(); +const openPanels = new WeakMap(); const ORDER = [ 'weather-cyclones', 'wind', @@ -97,7 +98,8 @@ export function createWeatherPanel({ let destroyed = false; let previousIds = null; let hasAppeared = false; - let openId = openDocuments.get(document) || null; + const page = panel || document; + let openId = openPanels.get(page) || null; // Open card as last shown; null until cards are on screen. let shownOpenId = null; const timeline = createRailTimeline({ @@ -118,7 +120,7 @@ export function createWeatherPanel({ onParams: (id, params) => setLayerParams(id, params, { origin: 'user' }), onOpen: (id) => { openId = id; - openDocuments.set(document, id); + openPanels.set(page, id); render(); }, }; @@ -260,8 +262,8 @@ export function createWeatherPanel({ set(root, 'hidden', hidden); if (count) set(count, 'textContent', String(active.length)); if (panel) set(panel, 'hidden', hidden); - if (!hidden && panel && !appearedDocuments.has(document)) { - appearedDocuments.add(document); + if (!hidden && panel && !appearedPanels.has(panel)) { + appearedPanels.add(panel); // First appearance opens the panel unless a stored or shared collapse // choice exists (marked by the panel chrome when it restored the state). const preference = panel.dataset?.collapsedPreference; @@ -284,7 +286,7 @@ export function createWeatherPanel({ previousIds && entries.filter(({ id }) => !previousIds.has(id)); if (hasAppeared && added?.length) { openId = added.at(-1).id; - openDocuments.set(document, openId); + openPanels.set(page, openId); } else if (ids.size && !ids.has(openId)) { openId = (!hasAppeared && diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index 70757c2f5..921121fcc 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -1,8 +1,9 @@ +import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; import { createWeatherPanel } from './weatherPanel.js'; -import { railFixture } from './railTestFixture.mjs'; +import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; import { createAirQualityLayer } from '../layers/airQuality/index.js'; const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z']; const wind = { @@ -20,14 +21,16 @@ const radar = { icon: '◉', summary: { label: 'Rain radar', coverage: 'CONUS' }, }; -function fixture(onWrite) { - const f = railFixture(onWrite); +function fixture() { + const f = railFixture(); const panel = f.document.createElement('div'); const count = f.document.createElement('span'); let collapsed = true; let clicks = 0; panel.hidden = true; - panel.classList = { contains: () => collapsed }; + Object.defineProperty(panel, 'classList', { + value: { contains: () => collapsed }, + }); const collapse = { click() { collapsed = !collapsed; @@ -112,7 +115,7 @@ test('ordered articles omit Controls and retain history labels, forecast dates a view.update([radar, wind]); const windCard = card(f, 'wind'); assert.equal(windCard.tagName, 'ARTICLE'); - assert.equal(windCard.parent.children[0], windCard); + assert.equal(windCard.parentElement.children[0], windCard); assert.equal( f.find((n) => n.dataset.weatherOpen), null, @@ -186,7 +189,9 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage, line(f, cyclone.id, 'geometry').textContent, /awaiting advisory 10/, ); - const link = f.find((n) => n.textContent === 'Official advisory ↗'); + const link = f.find( + (n) => n.tagName === 'A' && n.textContent === 'Official advisory ↗', + ); assert.equal(link.href, cyclone.summary.actions[0].href); assert.equal(link.tagName, 'A'); assert.equal(link.rel, 'noopener'); @@ -198,8 +203,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage, view.destroy(); }); test('identical updates write nothing and status keeps its line through loading and empty text', () => { - let writes = 0; - const f = fixture(() => writes++); + const f = fixture(); const view = createWeatherPanel(f); for (const entries of [ [wind, radar], @@ -209,9 +213,9 @@ test('identical updates write nothing and status keeps its line through loading ]) { view.update(entries); const status = entries.length ? line(f, 'wind', 'status') : null; - writes = 0; + f.writes(); view.update(entries); - assert.equal(writes, 0); + assert.equal(f.writes(), 0); if (status) { assert.equal(status.hidden, false); assert.equal(line(f, 'wind', 'status'), status); @@ -256,7 +260,7 @@ test('count, hidden-empty and first-appearance expansion survive remount and own test('a stored or shared collapse choice is not overridden on first appearance', () => { for (const preference of ['stored', 'share']) { const f = fixture(); - f.panel.dataset = { collapsedPreference: preference }; + f.panel.dataset.collapsedPreference = preference; const view = createWeatherPanel(f); view.update([radar]); assert.equal(f.panel.hidden, false); @@ -268,7 +272,7 @@ test('a stored or shared collapse choice is not overridden on first appearance', view.destroy(); } const f = fixture(); - f.panel.dataset = { collapsedPreference: 'default' }; + f.panel.dataset.collapsedPreference = 'default'; const view = createWeatherPanel(f); view.update([radar]); assert.equal(f.clicks(), 1, 'a default state still expands once'); @@ -326,7 +330,7 @@ test('settings, reading and footer actions pass the layer id, params and user or assert.equal(header.children[1].textContent, '×'); assert.equal(header.children[1].getAttribute('aria-label'), 'Clear reading'); assert.deepEqual( - result.children[1].children[0].children.map((n) => [ + [...result.children[1].children[0].children].map((n) => [ n.dataset.lineId, n.textContent, ]), @@ -348,7 +352,7 @@ test('settings, reading and footer actions pass the layer id, params and user or layer.destroy(); const body = card(f, 'wind').children[1]; assert.deepEqual( - body.children.map((n) => n.dataset.blockId), + [...body.children].map((n) => n.dataset.blockId), ['details', 'settings', 'actions', 'reading'], ); view.destroy(); @@ -395,7 +399,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti view.update([lightning, radar, wind, cyclone, satellite]); const root = f.container.children[0]; assert.deepEqual( - root.children[0].children.map((n) => n.dataset.cardId), + [...root.children[0].children].map((n) => n.dataset.cardId), ['weather-cyclones', 'wind'], ); const group = f.find((n) => n.className === 'weather-observed-group'); @@ -407,7 +411,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti ); assert.equal(group.children[2], timelineHost(f)); assert.deepEqual( - group.children[3].children.map((n) => n.dataset.cardId), + [...group.children[3].children].map((n) => n.dataset.cardId), ['weather-radar', 'weather-satellite', 'weather-lightning'], ); assert.deepEqual(opened(f), ['weather-cyclones']); @@ -579,7 +583,7 @@ test('all five headers retain descriptor icons and disclosure state when opened view.destroy(); }); -test('a change of open card scrolls its header into view once; refreshes never move the body', () => { +test('a change of open card scrolls its header into view once; refreshes never move the body', (t) => { const f = fixture(); const body = f.container; const writes = []; @@ -594,11 +598,14 @@ test('a change of open card scrolls its header into view once; refreshes never m scrollTop = value; }, }); - body.clientHeight = 200; + Object.defineProperty(body, 'clientHeight', { value: 200 }); body.getBoundingClientRect = () => ({ top: 100, height: 200 }); const create = f.document.createElement; + t.after(() => { + f.document.createElement = create; + }); f.document.createElement = (tag) => { - const node = create(tag); + const node = create.call(f.document, tag); node.getBoundingClientRect = function () { return rects.get(this.dataset.cardId) || { top: 0, height: 0 }; }; @@ -666,7 +673,7 @@ test('history time keeps the hour whatever the length of the localized date', () view.update([wind, radar]); const ends = f.find((n) => n.className === 'rail-timeline-endpoints'); assert.deepEqual( - ends.children.map((n) => n.textContent), + [...ends.children].map((n) => n.textContent), ['01:00 UTC', '01:05 UTC'], locale, ); @@ -734,7 +741,7 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir const entries = f.find((n) => n.className === 'rail-card-legend-entries', aq); assert.equal(entries.hidden, false); assert.deepEqual( - entries.children.map((entry) => entry.children[1].textContent), + [...entries.children].map((entry) => entry.children[1].textContent), ['Low', 'Moderate', 'High', 'Very High'], ); assert.equal( @@ -744,8 +751,8 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir const list = f.find((n) => n.dataset.blockId === 'list', aq); assert.equal(list.hidden, false); assert.deepEqual( - list.children.map((item) => - item.children[0].children.map((part) => part.textContent).join(' '), + [...list.children].map((item) => + [...item.children[0].children].map((part) => part.textContent).join(' '), ), ['AQHI 8 Hamilton · high risk', 'AQHI 2 Toronto · low risk'], );