Test the rails' new layout and translate the edge switch

CCTV and Context take the full right column when open, so the hysteresis
tests use a content-sized panel, and a test covers the full-height rule.

Co-authored-by: Manjunath Paparaju <[email protected]>
This commit is contained in:
Sameh KhamisandManjunath Paparaju committed 2026-11-14 13:42:06 -08:00
1 parent d0b614d88e
commit c54ead9432
16 files changed
+187 -53

No files matched your search

+127 -36
View File
@@ -4,18 +4,31 @@ import { _selectContextMode } from './ui/contextTransactions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
const radioBindings = readRadioSource(
new URL('./ui/radioBindings.js', import.meta.url),
'utf8',
);
const contextView = readRadioSource(
new URL('./ui/views/ContextView.vue', import.meta.url),
'utf8',
);
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`));
for (const id of [
'global-context-flights-btn',
'global-context-missions-btn',
]) {
const button = html.match(
new RegExp(`<button id="${id}"[\\s\\S]*?</button>`),
);
assert.ok(button, `${id} is missing`);
assert.match(button[0], /role="tab"/);
assert.match(button[0], /tabindex="0"/);
@@ -23,7 +36,10 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
// The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
for (const id of [
'global-context-flights-btn',
'global-context-missions-btn',
]) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
@@ -34,9 +50,22 @@ test('Context activation and Clear All never native-disable tabs and guard repea
const init = _initGlobalContextPanel.toString();
const select = _selectContextMode.toString();
const clear = clearSelectedLayers.toString();
assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2);
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.equal(
(
init.match(
/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g,
) || []
).length,
2,
);
assert.doesNotMatch(
select,
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
);
assert.doesNotMatch(
clear,
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
);
});
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
@@ -45,40 +74,62 @@ test('Context tablist retains Left, Right, Home, and End keyboard navigation', (
assert.match(initSource, /event\.key === 'ArrowLeft'/);
assert.match(initSource, /event\.key === 'Home'/);
assert.match(initSource, /event\.key === 'End'/);
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(
initSource,
/contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/,
);
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
});
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const focusRule = rules.find(([, selector, body]) => (
selector.trim().endsWith('.context-mode-button:focus-visible')
&& /outline:\s*2px solid var\(--text-primary\)/.test(body)
));
assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
const focusRule = rules.find(
([, selector, body]) =>
selector.trim().endsWith('.context-mode-button:focus-visible') &&
/outline:\s*2px solid var\(--text-primary\)/.test(body),
);
assert.ok(
focusRule,
'Context focus-visible rule must draw a two-pixel outline',
);
assert.match(focusRule[2], /outline-offset:\s*-3px/);
const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
const activeRule = rules.find(([, selector]) =>
selector.trim().endsWith('.context-mode-button.active'),
);
assert.ok(activeRule, 'Context active-state rule is missing');
assert.doesNotMatch(activeRule[2], /outline:\s*none/);
});
test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const radio = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
const radioPanel = readFileSync(
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
'utf8',
);
const clear = clearSelectedLayers.toString();
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
const search = contextView.match(
/<button id="installations-search-btn"[^>]*>/,
)?.[0];
assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
const cockpitRadio = readFileSync(
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
'utf8',
);
const cockpitEnable = cockpitRadio.match(
/<UiButton id="cockpit-radio-enable-btn"[^>]*>/,
)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/);
@@ -89,44 +140,84 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(enable, /:aria-busy=/);
assert.match(clear, /this\.setClearBusy\(true\)/);
// The globe actions view marks the clear button busy, never disabled.
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
const globeActions = readFileSync(
new URL('./ui/views/panels/GlobeActions.vue', import.meta.url),
'utf8',
);
const clearButton = globeActions.match(
/<button id="clear-selected-layers"[^>]*>/,
)?.[0];
assert.ok(clearButton, 'Clear selected layers button is missing');
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
assert.match(clearButton, /:aria-busy=/);
});
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
test('Context header holds only its title, edge switch and collapse control', () => {
const panel = html.match(
/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/,
);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
const header = panel[0].match(
/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/,
);
assert.ok(header, 'Global Context header is missing');
// The kit's own ui-* classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
const tags = [
...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g),
].map(
([, tag, cls]) =>
`${tag}.${cls
.split(' ')
.filter((name) => !/^ui-/.test(name))
.join(' ')}`,
);
assert.deepEqual(tags, [
'span.panel-title',
'span.panel-divider',
'button.panel-edge-btn',
'span.material-symbols-outlined',
'button.panel-collapse-btn',
]);
});
test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
const clean = (selector) =>
selector
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, ' ')
.trim();
const rule = (selector) =>
rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) =>
Number(
body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0,
);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/);
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
assert.ok(
px(rule('.context-mode-button'), 'font') >= 10,
'mode labels must stay at least 10px',
);
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby');
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
assert.ok(
px(standby, 'font') >= 10,
'standby guidance must stay at least 10px',
);
assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
assert.ok(
px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10,
);
// The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again.
assert.equal(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
rule(
":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title",
),
'',
);
});
+3 -1
View File
@@ -3967,5 +3967,7 @@
"Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك"
"Gondola": "تلفريك",
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
}
+3 -1
View File
@@ -3619,5 +3619,7 @@
"Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn",
"Gondola": "Gondelbahn"
"Gondola": "Gondelbahn",
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben"
}
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Tranvía de cable",
"Monorail": "Monorraíl",
"Funicular": "Funicular",
"Gondola": "Góndola"
"Gondola": "Góndola",
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde"
}
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Tramway à câble",
"Monorail": "Monorail",
"Funicular": "Funiculaire",
"Gondola": "Télécabine"
"Gondola": "Télécabine",
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord"
}
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "חשמלית כבלים",
"Monorail": "מונורייל",
"Funicular": "פוניקולר",
"Gondola": "רכבל"
"Gondola": "רכבל",
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
}
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Tram a fune",
"Monorail": "Monorotaia",
"Funicular": "Funicolare",
"Gondola": "Cabinovia"
"Gondola": "Cabinovia",
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo"
}
+3 -1
View File
@@ -3532,5 +3532,7 @@
"Cable car": "ケーブルトラム",
"Monorail": "モノレール",
"Funicular": "ケーブルカー",
"Gondola": "ゴンドラ"
"Gondola": "ゴンドラ",
"Move panels back beside the rail": "パネルをレールの横に戻す",
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
}
+3 -1
View File
@@ -3706,5 +3706,7 @@
"Cable car": "Bonde a cabo",
"Monorail": "Monotrilho",
"Funicular": "Funicular",
"Gondola": "Teleférico"
"Gondola": "Teleférico",
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda"
}
+3 -1
View File
@@ -3793,5 +3793,7 @@
"Cable car": "Канатный трамвай",
"Monorail": "Монорельс",
"Funicular": "Фуникулёр",
"Gondola": "Канатная дорога"
"Gondola": "Канатная дорога",
"Move panels back beside the rail": "Вернуть панели к боковой панели",
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
}
+3 -1
View File
@@ -3619,5 +3619,7 @@
"Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana",
"Funicular": "Bergbana",
"Gondola": "Kabinbana"
"Gondola": "Kabinbana",
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten"
}
+3 -1
View File
@@ -3793,5 +3793,7 @@
"Cable car": "Канатний трамвай",
"Monorail": "Монорейка",
"Funicular": "Фунікулер",
"Gondola": "Канатна дорога"
"Gondola": "Канатна дорога",
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
}
+3 -1
View File
@@ -3532,5 +3532,7 @@
"Cable car": "缆索电车",
"Monorail": "单轨列车",
"Funicular": "缆索铁路",
"Gondola": "缆车"
"Gondola": "缆车",
"Move panels back beside the rail": "将面板移回侧栏旁",
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
}
+3 -1
View File
@@ -3532,5 +3532,7 @@
"Cable car": "纜索電車",
"Monorail": "單軌列車",
"Funicular": "纜索鐵路",
"Gondola": "纜車"
"Gondola": "纜車",
"Move panels back beside the rail": "將面板移回側欄旁",
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
}
+1
View File
@@ -141,6 +141,7 @@ test("UiPanel draws Core's panel markup and asks for the other state", async (t)
'demo-badge',
'panel-divider',
'demo-action',
'panel-edge-btn',
'panel-collapse-btn',
],
);
+20 -4
View File
@@ -456,13 +456,17 @@ test('natural height includes visible content, margins and wrapper chrome, exclu
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
// Removing only the allocation leaves focus CSS active: its next reading is
// 422 px, so the old decision exits focus and then re-enters on the 629 px read.
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
function thrashingRightRail({
focused = false,
weatherOpen = false,
tallId = 'cctv-panel',
} = {}) {
const f = fixture('right');
f.options.windowRef.innerHeight = 920;
f.options.leftStack.rect.top = 439.6;
f.stack.computed.rowGap = '8px';
const display = element('pp-toggles', { collapsed: true });
const cctv = element('cctv-panel', { height: 629 });
const cctv = element(tallId, { height: 629 });
const weather = element('weather-panel', {
height: 444,
collapsed: !weatherOpen,
@@ -533,8 +537,10 @@ for (const focused of [false, true]) {
});
}
// The hysteresis applies to panels that size to their content; CCTV and
// Context take the column's full height whenever they are open.
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
const f = thrashingRightRail();
const f = thrashingRightRail({ tallId: 'news-panel' });
f.run();
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
for (const height of [368, 398, 368, 398, 368]) {
@@ -551,7 +557,7 @@ test('right rail keeps focus through 30 px content growth inside the hysteresis
});
test('right rail enters focus only above available height and leaves below the full deadband', () => {
const f = thrashingRightRail();
const f = thrashingRightRail({ tallId: 'news-panel' });
f.options.windowRef.innerHeight = 1000;
f.options.leftStack.rect.top = 500;
f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
@@ -568,6 +574,16 @@ test('right rail enters focus only above available height and leaves below the f
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('an open CCTV panel takes the full column however short its content', () => {
const f = thrashingRightRail();
f.cctv.intrinsicHeight = 200;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.classList.add('collapsed');
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail settles a second tall panel through the accordion in one pass', () => {
const f = thrashingRightRail({ weatherOpen: true });
const modes = [];