Files
RustProxy/tools/checks/dashboard_layout_editor_0323_ui_test.mjs
T
2026-10-13 21:29:57 +02:00

214 lines
13 KiB
JavaScript

// Browser-independent UI simulation of the actual visual Overview modal.
// This does not replace an authenticated DOM/browser acceptance test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.map(widget => widget.id);
const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel'])
assert(html.includes(`id="${id}"`), id);
class FakeNode {
constructor(tag = 'div', content = '', classes = '') {
this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean));
this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) };
this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map();
this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false;
}
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); }
fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); }
setAttribute(k, v) { this.attrs[k] = String(v); }
removeAttribute(k) { delete this.attrs[k]; }
focus() { this.focused = true; }
showModal() { this.open = true; }
close() { this.open = false; this.fire('close'); }
querySelectorAll(selector) {
const nodes = [];
function visit(node) {
for (const child of node.children) {
if (selector === '[id]' && child.attrs.id) nodes.push(child);
else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child);
else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child);
visit(child);
}
}
visit(this); return nodes;
}
querySelector(selector) {
return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) ||
(() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })();
}
cloneNode(deep = false) {
const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' '));
copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.children = this.children.map(node => node.cloneNode(true));
return copy;
}
}
const controls = Object.fromEntries([
'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden',
'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter',
'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings',
'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel',
].map(id => [id, new FakeNode()]));
const originals = new Map(ids.map(id => {
const node = new FakeNode('article', 'metric value', 'stat');
node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id;
node.append(new FakeNode('strong', '42'));
node.children[0].attrs.id = `metric-${id}`;
node.hidden = initialHidden.includes(id);
return [id, node];
}));
let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' };
const writes = [], notifications = [];
let failSave = false;
const window = { rustProxyPreferences: {
current: () => saved,
load: async () => saved,
update: async patch => {
if (failSave) throw Error('server refused');
writes.push(patch); saved = { ...saved, ...patch };
for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id);
return saved;
},
} };
const $ = id => controls[id];
const ctx = {
window, $, t: text => `loc:${text}`, toast: text => notifications.push(text),
el: (tag, content, cls) => new FakeNode(tag, content, cls),
ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])),
ADMIN_DASHBOARD_WIDGETS: [...ids],
ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden],
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] },
normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))],
dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type,
dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({
value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table',
})[registry.find(widget => widget.id === id)?.display_type]}`,
dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'),
document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(ctx); vm.runInContext(source, ctx);
const module = window.rustProxyDashboardLayout;
const dialog = controls['overview-layout-dialog'];
const visible = controls['overview-layout-visible'];
const hidden = controls['overview-layout-hidden'];
const refresh = () => new Promise(resolve => setImmediate(resolve));
const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget);
// Cancel changes, no PUT or change of real dashboard.
controls['overview-layout-open'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(previewIds().length, ids.length - initialHidden.length);
assert.equal(hidden.children.length, initialHidden.length);
assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value');
assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`);
controls['overview-layout-type-filter'].value = 'chart';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts');
controls['overview-layout-type-filter'].value = 'all';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.length, initialHidden.length);
const previewCopy = visible.children[0].children[1];
assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs');
assert.equal(previewCopy.children[0].attrs.id, undefined);
assert.equal(previewCopy.attrs['aria-hidden'], 'true');
const first = previewIds()[0];
visible.children[0].children[0].children[2].children[2].fire('click');
assert(!previewIds().includes(first));
assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards');
assert.equal(writes.length, 0);
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
assert.equal(writes.length, 0);
// Settings and Overview entry points are wired to the same editor.
controls['overview-layout-open-settings'].fire('click'); await refresh();
assert.equal(dialog.open, true);
const originalVisible = previewIds();
const from = visible.children[0], to = visible.children[2];
const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' };
from.fire('dragstart', { target: from, dataTransfer });
to.fire('dragover', { dataTransfer });
to.fire('drop', { dataTransfer });
assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target');
assert.equal(previewIds()[0], originalVisible[1]);
// Keyboard buttons move cards back, without rebuilding saved prefs prematurely.
visible.children[2].children[0].children[2].children[0].fire('click');
assert.equal(previewIds()[1], originalVisible[0]);
const firstHiddenId = initialHidden[0];
const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`);
hiddenItem.children[2].fire('click');
assert(previewIds().includes(firstHiddenId));
assert.equal(writes.length, 0);
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(writes.length, 1, 'one atomic preferences update');
assert.equal(dialog.open, false);
assert.equal(saved.theme, 'light', 'unrelated preferences are untouched');
assert.equal(new Set(saved.dashboard_widgets).size, ids.length);
assert.equal(saved.dashboard_widgets.length, ids.length);
assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId));
assert.equal(notifications.length, 1);
// Failed save keeps the dialog open and the unsaved draft available for retry.
controls['overview-layout-open'].fire('click'); await refresh();
failSave = true;
const writeCount = writes.length;
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(controls['overview-layout-message'].textContent, 'server refused');
assert.equal(writes.length, writeCount);
failSave = false;
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
// Default view is only a draft until Save: no implicit writes.
controls['overview-layout-open'].fire('click'); await refresh();
const writesBeforeDefault = writes.length;
controls['overview-layout-default'].fire('click');
assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id)));
assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded'));
assert.equal(writes.length, writesBeforeDefault);
assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor');
assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true);
// Reset first prompts for confirmation, cancellation must not change preferences.
controls['overview-layout-reset'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
controls['overview-reset-cancel'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(writes.length, writesBeforeDefault);
// A failed reset keeps the confirmation open and never claims success.
controls['overview-layout-reset'].fire('click'); failSave = true;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
failSave = false;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(dialog.open, false);
assert.equal(writes.length, writesBeforeDefault + 1);
assert.deepEqual([...saved.dashboard_widgets], ids);
assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden);
assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences');
console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures');
// Group boundaries are a UX constraint in the real browser: the modal must
// not let a small tile replace the position of a tall diagnostic panel.
window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 };
controls['overview-layout-open'].fire('click'); await refresh();
const beforeGroupMove = previewIds();
const small = visible.children.find(node => node.dataset.previewWidget === 'requests');
const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart');
small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} });
big.fire('drop');
assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups');
controls['overview-layout-cancel'].fire('click');
console.log('PASS: visual editor respects symmetric height groups');