* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
215 lines
11 KiB
JavaScript
215 lines
11 KiB
JavaScript
// System controls (#249 System S2 Task 5): the concept's `setting()` (app.js 624–634, its toggles hydrated into Toolkit
|
||
// switches by `hydrateSwitches` 708–717), `operation()` (645–654) and `sectionNav()` (306–308) as DOM builders whose
|
||
// listeners live on the caller's scope.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const controlsModule = import('../../web/js/workspace/system-controls.mjs');
|
||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||
|
||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||
function fakeDOM() {
|
||
const previous = global.document;
|
||
const elements = [];
|
||
class Element {
|
||
constructor(tag) {
|
||
this.tagName = tag.toUpperCase();
|
||
this.children = [];
|
||
this.attributes = {};
|
||
this.listeners = [];
|
||
this.dataset = {};
|
||
this.className = '';
|
||
this.id = '';
|
||
this.value = '';
|
||
this._text = '';
|
||
elements.push(this);
|
||
}
|
||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||
set textContent(text) { this.children = []; this._text = String(text); }
|
||
append(...nodes) {
|
||
for (const node of nodes) {
|
||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||
child.parentNode = this;
|
||
this.children.push(child);
|
||
}
|
||
}
|
||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||
removeAttribute(name) { delete this.attributes[name]; }
|
||
addEventListener(type, callback, options = {}) {
|
||
if (options.signal?.aborted) return;
|
||
const entry = { type, callback };
|
||
this.listeners.push(entry);
|
||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||
}
|
||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||
click() { this.dispatch('click'); }
|
||
}
|
||
global.document = {
|
||
createElement: tag => new Element(tag),
|
||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||
};
|
||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||
return {
|
||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||
find: (root, predicate) => walk(root).find(predicate),
|
||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||
restore() { global.document = previous; },
|
||
};
|
||
}
|
||
|
||
test('settingRow is the concept row: div > label[for] + p, then the control', async () => {
|
||
const { settingRow } = await controlsModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const changes = [];
|
||
const row = settingRow({ id: 'timeout', label: 'Node search auto-hide (seconds)',
|
||
description: 'Node search behavior only. Use 0 to keep results visible.',
|
||
control: { type: 'number', value: 30, min: 0, max: 300 }, scope, onChange: value => changes.push(value) });
|
||
assert.equal(row.className, 'setting-row');
|
||
assert.deepEqual(dom.tags(row), ['div', 'input']);
|
||
const [text, input] = row.children;
|
||
assert.deepEqual(dom.tags(text), ['label', 'p']);
|
||
assert.equal(text.children[0].textContent, 'Node search auto-hide (seconds)');
|
||
assert.equal(text.children[0].htmlFor, 'timeout');
|
||
assert.equal(text.children[1].textContent, 'Node search behavior only. Use 0 to keep results visible.');
|
||
assert.deepEqual([input.id, input.type, input.value, input.min, input.max], ['timeout', 'number', '30', '0', '300']);
|
||
assert.equal(input.dataset.setting, 'timeout');
|
||
input.value = '45';
|
||
input.dispatch('change');
|
||
assert.deepEqual(changes, [45]);
|
||
|
||
const select = settingRow({ id: 'open-mode', label: 'Open from ComfyUI', description: 'Keep the node pack’s popup / new-tab preference.',
|
||
control: { type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']], value: 'popup' }, scope,
|
||
onChange: value => changes.push(value) }).children[1];
|
||
assert.equal(select.tagName, 'SELECT');
|
||
assert.deepEqual(select.children.map(option => [option.value, option.textContent]), [['newtab', 'New tab'], ['popup', 'Popup window']]);
|
||
assert.equal(select.value, 'popup');
|
||
select.value = 'newtab';
|
||
select.dispatch('change');
|
||
assert.deepEqual(changes, [45, 'newtab']);
|
||
|
||
// The concept's range is 1–16 unless the row says otherwise (D113: the CPU count).
|
||
const range = settingRow({ id: 'workers', label: 'Worker threads', description: 'Balance background scans.',
|
||
control: { type: 'range', value: 4 } }).children[1];
|
||
assert.deepEqual([range.type, range.min, range.max, range.value], ['range', '1', '16', '4']);
|
||
const password = settingRow({ id: 'civitai-key', label: 'CivitAI API key', description: 'Credentials stay on the server.',
|
||
control: { type: 'password', value: '' } }).children[1];
|
||
assert.equal(password.type, 'password');
|
||
|
||
// A ready-made control is placed as given and takes the row id.
|
||
const custom = document.createElement('textarea');
|
||
const customRow = settingRow({ id: 'custom', label: 'Custom', description: 'Any control.', control: custom });
|
||
assert.equal(customRow.children[1], custom);
|
||
assert.equal(custom.id, 'custom');
|
||
assert.throws(() => settingRow({ id: '', label: 'x', description: 'y', control: { type: 'number' } }), TypeError);
|
||
assert.throws(() => settingRow({ id: 'x', label: 'x', description: 'y', control: { type: 'colour' } }), TypeError);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('a toggle row holds the Toolkit switch, labelled by the row label', async () => {
|
||
const { settingRow } = await controlsModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const changes = [];
|
||
const row = settingRow({ id: 'video-auto', label: 'Autoplay videos', description: 'Videos stay within the same gallery.',
|
||
control: { type: 'toggle', checked: false }, scope, onChange: value => changes.push(value) });
|
||
const field = row.children[1];
|
||
// createSwitch('', '', id): `label.ca-switch` > input[type=checkbox][role=switch] + an empty label text.
|
||
assert.equal(field.tagName, 'LABEL');
|
||
assert.equal(field.className, 'ca-switch');
|
||
const input = field.children[0];
|
||
assert.deepEqual([input.type, input.role, input.id, input.checked], ['checkbox', 'switch', 'video-auto', false]);
|
||
assert.equal(row.children[0].children[0].htmlFor, input.id);
|
||
assert.equal(input.dataset.setting, 'video-auto');
|
||
input.checked = true;
|
||
input.dispatch('change');
|
||
assert.deepEqual(changes, [true]);
|
||
// The concept's toggles are on unless configured off (`val !== false`).
|
||
const on = settingRow({ id: 'video-loop', label: 'Loop videos', description: 'Repeat a clip.', control: { type: 'toggle' } });
|
||
assert.equal(on.children[1].children[0].checked, true);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('operationRow is the concept operation with a danger class only when flagged', async () => {
|
||
const { operationRow } = await controlsModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const clicks = [];
|
||
const row = operationRow({ title: 'Missing-file links',
|
||
description: 'Remove stale database links only. Files on disk are never deleted by this operation.',
|
||
label: 'Review plan', danger: true, scope, onClick: button => clicks.push(button) });
|
||
assert.equal(row.className, 'operation');
|
||
assert.deepEqual(dom.tags(row), ['div', 'div']);
|
||
assert.deepEqual(dom.tags(row.children[0]), ['h3', 'p']);
|
||
assert.equal(row.children[0].children[0].textContent, 'Missing-file links');
|
||
assert.equal(row.children[1].className, 'row-actions');
|
||
const button = row.children[1].children[0];
|
||
assert.deepEqual([button.tagName, button.type, button.textContent, button.className], ['BUTTON', 'button', 'Review plan', 'danger']);
|
||
button.click();
|
||
assert.deepEqual(clicks, [button]);
|
||
const plain = operationRow({ title: 'Statistics', description: 'Usage by prompt, category, tag and date.', label: 'Explore',
|
||
scope, onClick() {} });
|
||
assert.equal(plain.children[1].children[0].className, '');
|
||
assert.throws(() => operationRow({ title: 'x', description: 'y', label: 'z', onClick() {} }), TypeError);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('sectionNav marks the active section and reports the chosen one', async () => {
|
||
const { sectionNav } = await controlsModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const chosen = [];
|
||
const nav = sectionNav({ label: 'System sections', sections: [['overview', 'Overview'], ['appearance', 'Appearance']],
|
||
active: 'appearance', scope, onSelect: id => chosen.push(id) });
|
||
assert.equal(nav.className, 'section-nav');
|
||
assert.equal(nav.getAttribute('role'), 'group');
|
||
assert.equal(nav.getAttribute('aria-label'), 'System sections');
|
||
assert.deepEqual(nav.children.map(button => [button.textContent, button.getAttribute('aria-pressed'), button.dataset.value]),
|
||
[['Overview', 'false', 'overview'], ['Appearance', 'true', 'appearance']]);
|
||
nav.children[0].click();
|
||
assert.deepEqual(chosen, ['overview']);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('disposing the scope leaves no listener behind', async () => {
|
||
const { settingRow, operationRow, sectionNav } = await controlsModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
settingRow({ id: 'a', label: 'A', description: 'a', control: { type: 'toggle' }, scope, onChange() {} });
|
||
settingRow({ id: 'b', label: 'B', description: 'b', control: { type: 'select', options: ['Grid', 'List'] }, scope, onChange() {} });
|
||
operationRow({ title: 'C', description: 'c', label: 'Go', scope, onClick() {} });
|
||
sectionNav({ label: 'System sections', sections: [['overview', 'Overview']], active: 'overview', scope, onSelect() {} });
|
||
assert.equal(dom.listenerCount() > 0, true);
|
||
scope.dispose();
|
||
assert.equal(dom.listenerCount(), 0);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|