diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js index ec6d33191..ce35ada45 100644 --- a/scripts/lib/control-pane/control-plane-view-ui.js +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -50,6 +50,8 @@ function renderControlPlaneViewHtml() { #legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; } #meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; } .shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; } + /* Off-screen, not display:none, so assistive tech still reads the node. */ + .sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; } @@ -67,6 +69,7 @@ function renderControlPlaneViewHtml() {
■traffic advisory (transmit)
▲resolution advisory (steer)
+

Events

@@ -80,6 +83,9 @@ function renderControlPlaneViewHtml() { var canvas = document.getElementById('c'); var ctx = canvas.getContext('2d'); var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } }; + // Last advisory/steering pair handed to the live region, so a poll that + // changes nothing stays silent. + var lastSpoken = null; function resize() { var r = canvas.parentElement.getBoundingClientRect(); @@ -244,14 +250,26 @@ function renderControlPlaneViewHtml() { view = Object.assign({}, data); renderEvents(); renderLanes(); draw(); var c = view.counts || {}; - canvas.setAttribute('aria-label', - (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' + + var summary = (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' + (c.advisories || 0) + ' advisories, ' + (c.resolutions || 0) + ' steering. ' + - 'See the Lanes panel for per-task risk.'); + 'See the Lanes panel for per-task risk.'; + canvas.setAttribute('aria-label', summary); document.getElementById('status').textContent = (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' + (c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' + (view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : ''); + + // Polling runs every few seconds, so only speak when the advisory and + // steering counts actually move. Repeating an unchanged summary would talk + // over the operator without telling them anything new. + var speaker = document.getElementById('announce'); + var spoken = (c.advisories || 0) + '/' + (c.resolutions || 0); + if (spoken !== lastSpoken) { + lastSpoken = spoken; + speaker.textContent = (c.advisories || 0) + ' advisories, ' + + (c.resolutions || 0) + ' steering. ' + + ((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.'); + } } function poll() { diff --git a/tests/lib/control-plane-view-ui-a11y.test.js b/tests/lib/control-plane-view-ui-a11y.test.js index b484c4c0e..df0c8a38b 100644 --- a/tests/lib/control-plane-view-ui-a11y.test.js +++ b/tests/lib/control-plane-view-ui-a11y.test.js @@ -15,6 +15,13 @@ assert.ok(html.includes('■traffic advisory'), 'legend should show the a assert.ok(html.includes('▲resolution'), 'legend should show the resolution triangle marker'); assert.ok(!html.includes('class="dot"'), 'legend should not render color-only dots'); +// Counts are polled every few seconds, so a screen-reader user needs a polite +// live region to hear an advisory move. The canvas keeps its own label as the +// on-demand description. +assert.ok(html.includes('role="status" aria-live="polite"'), + 'polled counts should be announced through a polite live region'); +assert.ok(html.includes('class="sr"'), 'the live region should be hidden visually but not removed from the tree'); + // Clear and resolution must stay separable when hue is unavailable, so their // relative luminances have to differ by more than the ~1.05:1 that used to // collapse "clear" and "steer now" into the same grey. @@ -47,7 +54,7 @@ const [clear, traffic, resolution] = legend.map(entry => entry.color); // The legend and the canvas must agree, otherwise the operator reads a different // colour from the one the marker is drawn in. -const riskColorBody = html.match(/function riskColor\(risk\) \{([\s\S]*?)\n \}/)[1]; +const riskColorBody = html.match(/function riskColor\(risk\) \{([\s\S]*?)\n {2}\}/)[1]; const canvasColors = [...riskColorBody.matchAll(/return '(#[0-9a-f]{6})';/g)].map(match => match[1]); assert.deepStrictEqual(canvasColors, [resolution, traffic, clear], 'the legend palette and the riskColor palette must match'); @@ -62,4 +69,4 @@ for (const level of legend) { `the ${level.color} marker must meet 4.5:1 against the page background, got ${contrastRatio(level.color, BACKGROUND).toFixed(2)}:1`); } -console.log('Results: Passed: 15, Failed: 0'); +console.log('Results: Passed: 17, Failed: 0'); diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js index 5fc32f712..ce58a6f04 100644 --- a/tests/lib/control-plane-view-ui.test.js +++ b/tests/lib/control-plane-view-ui.test.js @@ -13,17 +13,86 @@ const proximityHtml = renderProximityVizHtml(); assert.ok(proximityHtml.includes('grid-template-rows: minmax(0, 1fr)')); assert.ok(proximityHtml.includes('#stage { position: relative; height: 100%; min-height: 0;')); -async function renderResponse(ok, data) { - const elements = new Map(); - const context = new Proxy({}, { get: () => () => {} }); - function element() { - return { textContent: '', style: {}, appendChild() {}, setAttribute() {}, getContext: () => context, - clientWidth: 640, clientHeight: 480, - parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) } }; +// A recording 2D context, so a test can assert what the view actually drew +// rather than only what the template happens to contain. +function createContext() { + const log = []; + const context = { fillStyle: '', strokeStyle: '', lineWidth: 1, globalAlpha: 1, font: '', log }; + const record = fn => (...args) => { log.push({ fn, args, fillStyle: context.fillStyle }); }; + for (const fn of ['setTransform', 'clearRect', 'beginPath', 'moveTo', 'lineTo', 'stroke', + 'arc', 'rect', 'closePath', 'fill', 'fillText', 'save', 'restore']) { + context[fn] = record(fn); } + return context; +} + +// Group the draw calls into paths and keep the filled ones: those are the risk +// markers, and the axis and pair-link paths only stroke. +function markerShapes(context) { + const paths = []; + let current = null; + for (const entry of context.log) { + if (entry.fn === 'beginPath') { + if (current) paths.push(current); + current = []; + continue; + } + if (!current) current = []; + current.push(entry); + } + if (current) paths.push(current); + return paths.filter(path => path.some(entry => entry.fn === 'fill')).map(path => { + const shape = path.some(e => e.fn === 'arc') ? 'circle' + : path.some(e => e.fn === 'rect') ? 'square' : 'triangle'; + return { shape, color: path.find(e => e.fn === 'fill').fillStyle }; + }); +} + +function textOf(node) { + return (node.textContent || '') + node.children.map(textOf).join(''); +} + +function findAll(node, className) { + const found = node.className === className ? [node] : []; + for (const child of node.children) found.push(...findAll(child, className)); + return found; +} + +function element(tag, context) { + const node = { + tag: tag || 'div', + className: '', + children: [], + style: {}, + attributes: {}, + clientWidth: 640, + clientHeight: 480, + parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) }, + appendChild(child) { node.children.push(child); return child; }, + setAttribute(name, value) { node.attributes[name] = value; }, + getContext: () => context + }; + let text = ''; + let writes = 0; + Object.defineProperty(node, 'textContent', { + get() { return text; }, + set(value) { text = String(value); node.children = []; writes += 1; }, + configurable: true + }); + Object.defineProperty(node, 'writes', { get() { return writes; }, configurable: true }); + return node; +} + +// Drives the view's inline script against a queue of poll responses, so one run +// can cover several polls and the state each one leaves behind. +async function render(responses) { + const context = createContext(); + const elements = new Map(); + const timers = []; + const queue = responses.slice(); const document = { - getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); }, - createElement: element + getElementById(id) { if (!elements.has(id)) elements.set(id, element(null, context)); return elements.get(id); }, + createElement: tag => element(tag, context) }; const html = renderControlPlaneViewHtml(); const start = html.indexOf('