214 lines
13 KiB
JavaScript
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');
|