122 lines
7.3 KiB
JavaScript
122 lines
7.3 KiB
JavaScript
/* AI disclosure labels in the inline editor: labels follow image swaps and media library changes. */
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const root = path.join(__dirname, '..');
|
|
const editor = fs.readFileSync(path.join(root, 'js/inline-editor.js'), 'utf8');
|
|
const manager = fs.readFileSync(path.join(root, 'js/image-manager.js'), 'utf8');
|
|
const footer = fs.readFileSync(path.join(root, 'includes/footer.php'), 'utf8');
|
|
const dashboard = fs.readFileSync(path.join(root, 'admin/dashboard.php'), 'utf8');
|
|
const settingsScript = fs.readFileSync(path.join(root, 'admin/dashboard/scripts/settings.php'), 'utf8');
|
|
const settingsApi = fs.readFileSync(path.join(root, 'admin/api/settings.php'), 'utf8');
|
|
|
|
function implementation(name) {
|
|
const match = editor.match(new RegExp(` function ${name}\\([^]*?\\n }`));
|
|
assert.ok(match, name);
|
|
return match[0];
|
|
}
|
|
const listener = editor.match(/ document\.addEventListener\('raiju:ai-label-change', [^]*?\n }\);/);
|
|
assert.ok(listener, 'inline editor listens for label changes from the media library');
|
|
|
|
// Minimal DOM: nodes with a parent, siblings in document order
|
|
function node(parent, props = {}) {
|
|
const n = Object.assign({ parent, className: '', attrs: {}, children: [] }, props);
|
|
n.classList = { contains: cls => n.className.split(/\s+/).includes(cls) };
|
|
n.getAttribute = name => (name in n.attrs ? n.attrs[name] : null);
|
|
n.closest = selector => {
|
|
for (let el = n; el; el = el.parent) if (el.classList.contains(selector.replace(/^\./, ''))) return el;
|
|
return null;
|
|
};
|
|
Object.defineProperty(n, 'nextElementSibling', { get: () => n.parent.children[n.parent.children.indexOf(n) + 1] || null });
|
|
n.remove = () => n.parent.children.splice(n.parent.children.indexOf(n), 1);
|
|
n.insertAdjacentHTML = (where, html) => {
|
|
assert.equal(where, 'afterend');
|
|
const label = node(null, { className: html.match(/class="([^"]+)"/)[1], html });
|
|
label.parent = n.parent;
|
|
n.parent.children.splice(n.parent.children.indexOf(n) + 1, 0, label);
|
|
};
|
|
if (parent) parent.children.push(n);
|
|
return n;
|
|
}
|
|
const labels = frame => frame.children.filter(child => child.classList.contains('rj-ai-label')).map(child => child.className.replace('rj-ai-label rj-ai-label--', ''));
|
|
const markup = kind => `<span class="rj-ai-label rj-ai-label--${kind}"><img class="rj-ai-label__badge" src="../css/ai-labels/de-${kind}.svg" alt=""></span>`;
|
|
|
|
const page = node(null);
|
|
const frameEditable = node(page, { className: 'frame' });
|
|
const wrapper = node(frameEditable, { className: 'editable-image-wrapper' });
|
|
const editableImg = node(wrapper, { attrs: { src: '/assets/images/a.webp', 'data-editable-image': '' } });
|
|
node(frameEditable, { className: 'rj-ai-label rj-ai-label--generated' });
|
|
const frameBound = node(page, { className: 'frame' });
|
|
const boundImg = node(frameBound, { attrs: { src: 'assets/images/photo.webp', 'data-rj-bind': 'image' } });
|
|
node(frameBound, { className: 'caption' });
|
|
|
|
const handlers = {};
|
|
const context = vm.createContext({
|
|
URL,
|
|
location: { href: 'http://localhost/de/seite/' },
|
|
window: { RJ_AI_LABELS: { files: { image: { 'a.webp': 'generated', 'team/b c.webp': 'modified' } }, markup: { generated: markup('generated'), modified: markup('modified') } } },
|
|
document: {
|
|
addEventListener: (type, handler) => { handlers[type] = handler; },
|
|
querySelectorAll: selector => {
|
|
assert.equal(selector, 'img[data-editable-image], img[data-rj-bind="image"]');
|
|
return [editableImg, boundImg];
|
|
}
|
|
}
|
|
});
|
|
vm.runInContext(implementation('aiLabelKindForSrc') + '\n' + implementation('syncAiLabel') + '\n' + listener[0], context);
|
|
const { aiLabelKindForSrc, syncAiLabel } = context;
|
|
|
|
// Sources map to the stored label like raijuMediaRefFromSrc() in PHP
|
|
assert.equal(aiLabelKindForSrc('/assets/images/a.webp'), 'generated');
|
|
assert.equal(aiLabelKindForSrc('../assets/images/team/b%20c.webp?v=2'), 'modified');
|
|
assert.equal(aiLabelKindForSrc('https://cdn.example.org/other.webp'), '');
|
|
assert.equal(aiLabelKindForSrc('/assets/images/constructor'), '', 'only own entries count as labels');
|
|
|
|
// Image swaps: remove, add after the editor wrapper, switch the kind
|
|
editableImg.attrs.src = '/assets/images/photo.webp';
|
|
syncAiLabel(editableImg);
|
|
assert.deepEqual(labels(frameEditable), [], 'swapping to an unlabelled image removes the label');
|
|
editableImg.attrs.src = '/assets/images/a.webp';
|
|
syncAiLabel(editableImg);
|
|
assert.deepEqual(labels(frameEditable), ['generated'], 'swapping to a labelled image adds the label');
|
|
assert.equal(frameEditable.children.indexOf(wrapper) + 1, frameEditable.children.findIndex(child => child.classList.contains('rj-ai-label')), 'the label follows the editor wrapper, not the image inside it');
|
|
editableImg.attrs.src = '/assets/images/team/b%20c.webp';
|
|
syncAiLabel(editableImg);
|
|
assert.deepEqual(labels(frameEditable), ['modified'], 'a different label kind replaces the label');
|
|
const labelNode = () => frameEditable.children.find(child => child.classList.contains('rj-ai-label'));
|
|
const current = labelNode();
|
|
syncAiLabel(editableImg);
|
|
assert.equal(labelNode(), current, 'an unchanged label stays in place, so refreshes do not make labels flicker');
|
|
assert.deepEqual(labels(frameEditable), ['modified']);
|
|
|
|
// Bound images (contentBindAttrs) get their label directly after the image
|
|
boundImg.attrs.src = 'assets/images/a.webp';
|
|
syncAiLabel(boundImg);
|
|
assert.deepEqual(frameBound.children.map(child => child === boundImg ? 'img' : child.className.split(' ').pop()), ['img', 'rj-ai-label--generated', 'caption']);
|
|
|
|
// Label changes in the media library update every image showing the file
|
|
boundImg.attrs.src = 'assets/images/photo.webp';
|
|
syncAiLabel(boundImg);
|
|
handlers['raiju:ai-label-change']({ detail: { type: 'image', name: 'photo.webp', label: 'modified' } });
|
|
assert.deepEqual(labels(frameBound), ['modified'], 'a new label shows up right away');
|
|
handlers['raiju:ai-label-change']({ detail: { type: 'image', name: 'team/b c.webp', label: '' } });
|
|
assert.deepEqual(labels(frameEditable), [], 'a removed label disappears right away');
|
|
|
|
// Pages without label data (older site footers) are left alone
|
|
context.window.RJ_AI_LABELS = undefined;
|
|
syncAiLabel(boundImg);
|
|
assert.deepEqual(labels(frameBound), ['modified']);
|
|
|
|
// Wiring: editor data, image changes and media library events
|
|
assert.ok(footer.includes('window.RJ_AI_LABELS = <?php echo json_encode(raijuAiLabelEditorConfig('), 'footer passes label data to the editor');
|
|
assert.ok((editor.match(/syncAiLabel\((img|el|element|heroEl)\);/g) || []).length >= 5, 'editor syncs labels wherever it changes an image');
|
|
assert.ok((manager.match(/announceAiLabel\(/g) || []).length >= 4, 'media library announces label changes, detections and replacements');
|
|
|
|
// Label artwork: English by default, German on German pages when selected under Settings → Language
|
|
assert.ok(dashboard.includes('id="settingsAiLabelArtwork"'), 'language settings offer the label artwork choice');
|
|
assert.ok(settingsScript.includes('settings.general.aiLabelArtwork ='), 'the language form saves the label artwork choice');
|
|
assert.ok(/'general' => \[[^\]]*'aiLabelArtwork'/.test(settingsApi), 'the settings API accepts the label artwork choice');
|
|
|
|
console.log(JSON.stringify({ ok: true }));
|