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'], );