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

236 lines
13 KiB
JavaScript

// Offline simulation of direct Overview arrangement. No authenticated browser
// or Rust networking is exercised by this test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = widgets.map(w => w.id);
const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save',
'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid'])
assert(html.includes(`id="${id}"`), `missing ${id}`);
class Node {
constructor(tag = 'div', value = '', cls = '') {
this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean));
this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)),
remove: (...names) => names.forEach(name => this.classes.delete(name)) };
this.children = []; this.listeners = new Map(); this.dataset = {};
this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null;
}
append(...children) {
for (const child of children) {
child.remove(); this.children.push(child); child.parent = this;
}
}
replaceChildren(...children) {
for (const child of this.children) child.parent = null;
this.children = [];
this.append(...children);
}
remove() {
if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); }
this.parent = null;
}
closest(selector) {
for (let cur = this; cur; cur = cur.parent) {
if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur;
if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur;
}
return null;
}
contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); }
querySelector(selector) {
for (const child of this.children) {
if (child.classes.has(selector.slice(1)) || child.tag === selector) return child;
const nested = child.querySelector(selector);
if (nested) return nested;
}
return null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getBoundingClientRect() { return { left: 0, width: 100 }; }
addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); }
fire(name, event = {}) {
const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event };
for (const cb of this.listeners.get(name) || []) cb(e);
return e;
}
focus() { this.focused = true; }
}
const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock',
'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel',
'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list',
'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()]));
const grid = controls.get('dashboard-overview-grid');
controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks'));
const cards = new Map(ids.map(id => {
const node = new Node('article'); node.dataset.dashboardWidget = id;
node.hidden = hiddenDefault.includes(id);
node.setAttribute('id', `real-${id}`);
node.append(new Node('strong', id));
grid.append(node);
return [id, node];
}));
let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' };
const writes = [], notifications = [];
let reject = false;
const window = {
rustProxyPreferences: {
load: async () => current,
current: () => current,
update: async patch => {
if (reject) throw Error('server refused');
writes.push(patch);
current = { ...current, ...patch };
applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets);
return current;
},
},
};
function applyDashboardLayout(order, hidden) {
for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); }
}
const $ = id => controls.get(id);
const ctx = {
window, $, el: (tag, value, cls) => new Node(tag, value, cls),
t: key => `loc:${key}`, toast: msg => notifications.push(msg),
dashboardWidgetLabel: id => `label:${id}`,
dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'),
applyDashboardLayout,
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] },
normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))],
};
vm.createContext(ctx);
vm.runInContext(source, ctx);
const editor = window.rustProxyInlineLayout;
const refresh = () => new Promise(resolve => setImmediate(resolve));
const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget);
const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length;
const button = (id, cls) => cards.get(id).querySelector(cls);
const grip = id => button(id, '.overview-inline-grip');
const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' });
// Opening does not clone cards or trigger network writes. Only the handle drags.
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(editor.isEditing());
assert.equal(controls.get('overview-inline-actions').hidden, false);
assert.equal(toolbarCount(), 62);
assert.equal(cards.get('requests').attributes.id, 'real-requests');
assert.equal(grip('requests').dataset.overviewDragHandle, 'requests');
const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() });
assert(accidental.prevented);
assert.equal(writes.length, 0);
// Hide removes the real block from the current view, not the registry; restore
// works immediately without an API call, and Cancel reverts an uncommitted hide.
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
assert(controls.get('overview-inline-hidden').open);
assert.equal(controls.get('overview-inline-hidden').hidden, false);
assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length);
assert.equal(writes.length, 0);
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:requests'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft');
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
controls.get('overview-inline-cancel').fire('click');
assert(!cards.get('requests').hidden, 'cancel restores the original visibility');
assert.equal(controls.get('overview-inline-hidden').hidden, true);
assert.equal(writes.length, 0);
controls.get('overview-layout-unlock').fire('click'); await refresh();
const transfer = dataTransfer();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer });
assert.equal(transfer['text/plain'], 'requests');
grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']);
assert.equal(writes.length, 0);
button('requests', '.overview-inline-up').fire('click');
assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']);
button('traffic_chart', '.overview-inline-up').fire('click');
assert.equal(writes.length, 0);
controls.get('overview-inline-cancel').fire('click');
assert(!editor.isEditing());
assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id)));
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 0);
// Saving writes exactly one ordered/hidden patch and retains other preferences.
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('active'), clientX: 90 });
reject = true;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(editor.isEditing(), 'failed PUT preserves the draft');
assert.equal(controls.get('overview-inline-hint').textContent, 'server refused');
assert.equal(writes.length, 0);
reject = false;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 1);
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault);
assert.equal(current.theme, 'light');
assert.equal(notifications.length, 1);
// Locking without changes does not write again. Navigation discard and dialog guard.
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(writes.length, 1);
controls.get('overview-layout-dialog').open = true;
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal');
controls.get('overview-layout-dialog').open = false;
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 0 });
assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
editor.cancel();
assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
assert.equal(writes.length, 1);
console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard');
// Hide persists across unlock sessions and the list allows recovery without
// touching unrelated account settings. The same API path handles a large panel.
controls.get('overview-layout-unlock').fire('click'); await refresh();
button('backend_table', '.overview-inline-hide').fire('click');
assert(cards.get('backend_table').hidden);
assert.equal(writes.length, 1);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 2);
assert(current.dashboard_hidden_widgets.includes('backend_table'));
assert(cards.get('backend_table').hidden);
assert.equal(current.theme, 'light');
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:backend_table'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('backend_table').hidden);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3);
assert(!current.dashboard_hidden_widgets.includes('backend_table'));
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence');
// In the real browser the row controller groups cards by footprint. A cross-
// group drop should not appear to move a card into an incompatible-height row.
window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0};
const beforeGroups = [...current.dashboard_widgets];
controls.get('overview-layout-unlock').fire('click'); await refresh();
const beforeCrossDrop = visible();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 50 });
assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups');
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3, 'blocked cross-group move must not write');
assert.deepEqual([...current.dashboard_widgets], beforeGroups);
console.log('PASS: cross-size drops cannot break symmetric grid groups');