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