fix: resolve event-listener leaks and CSS gaps found in code quality audit
- notes.js (Critical): move grid click listener from renderGrid() to render() — was re-registered on every save/pin/delete, causing multiple API calls per user action after several interactions - dashboard.js (Major): introduce AbortController (_fabController) so the anonymous document click listener from initFab() is cancelled on each new render() cycle; also remove the redundant initFab() call on the skeleton render - layout.css (Major): extend .label selector to include .form-label, covering usage in notes.js and settings.js without a mass-rename - test-modal-utils.js (Major): 12 unit tests for wireBlurValidation, btnSuccess, btnError; registered as test:modal-utils in package.json - notes.js (Minor): add btnError() shake feedback to save error handler - calendar.js (Minor): add popup.isConnected guard to closePopup so the listener self-removes correctly after navigation without a click Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Tests: Modal Utilities (wireBlurValidation, btnSuccess, btnError)
|
||||
* Modul: /public/components/modal.js
|
||||
* Läuft im Node-Kontext — die Utility-Funktionen greifen ausschließlich
|
||||
* über ihre Parameter auf DOM-Objekte zu, daher kein DOM-Polyfill nötig.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { wireBlurValidation, btnSuccess, btnError } = await import('./public/components/modal.js');
|
||||
|
||||
const _origSetTimeout = setTimeout;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DOM-Mocks
|
||||
// --------------------------------------------------------
|
||||
|
||||
function makeField() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
classList: {
|
||||
toggle(cls, force) { force ? classes.add(cls) : classes.delete(cls); },
|
||||
contains(cls) { return classes.has(cls); },
|
||||
},
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInput({ value = '', required = true } = {}) {
|
||||
const listeners = {};
|
||||
const field = makeField();
|
||||
return {
|
||||
value,
|
||||
required,
|
||||
_field: field,
|
||||
_listeners: listeners,
|
||||
addEventListener(event, fn) { listeners[event] = fn; },
|
||||
closest() { return field; },
|
||||
parentElement: field,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContainer(inputs = []) {
|
||||
return {
|
||||
querySelectorAll(selector) {
|
||||
if (selector.includes('required')) return inputs;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeBtn({ textContent = 'Speichern' } = {}) {
|
||||
const classes = new Set();
|
||||
const listeners = {};
|
||||
return {
|
||||
textContent,
|
||||
innerHTML: '',
|
||||
offsetWidth: 0,
|
||||
classList: {
|
||||
add(cls) { classes.add(cls); },
|
||||
remove(cls) { classes.delete(cls); },
|
||||
contains(cls) { return classes.has(cls); },
|
||||
},
|
||||
addEventListener(event, fn) { listeners[event] = fn; },
|
||||
_classes: classes,
|
||||
_listeners: listeners,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// wireBlurValidation
|
||||
// --------------------------------------------------------
|
||||
|
||||
test('wireBlurValidation: registriert blur-Listener auf required inputs', () => {
|
||||
const input = makeInput();
|
||||
wireBlurValidation(makeContainer([input]));
|
||||
assert.equal(typeof input._listeners['blur'], 'function');
|
||||
});
|
||||
|
||||
test('wireBlurValidation: blur mit leerem Wert setzt form-field--error', () => {
|
||||
const input = makeInput({ value: '' });
|
||||
wireBlurValidation(makeContainer([input]));
|
||||
input._listeners['blur']();
|
||||
assert.ok(input._field._classes.has('form-field--error'));
|
||||
assert.ok(!input._field._classes.has('form-field--valid'));
|
||||
});
|
||||
|
||||
test('wireBlurValidation: blur mit gültigem Wert setzt form-field--valid', () => {
|
||||
const input = makeInput({ value: 'Hallo' });
|
||||
wireBlurValidation(makeContainer([input]));
|
||||
input._listeners['blur']();
|
||||
assert.ok(input._field._classes.has('form-field--valid'));
|
||||
assert.ok(!input._field._classes.has('form-field--error'));
|
||||
});
|
||||
|
||||
test('wireBlurValidation: Whitespace-only gilt als leer → form-field--error', () => {
|
||||
const input = makeInput({ value: ' ' });
|
||||
wireBlurValidation(makeContainer([input]));
|
||||
input._listeners['blur']();
|
||||
assert.ok(input._field._classes.has('form-field--error'));
|
||||
});
|
||||
|
||||
test('wireBlurValidation: kein Fehler wenn closest() null zurückgibt', () => {
|
||||
const input = makeInput({ value: '' });
|
||||
input.closest = () => null;
|
||||
input.parentElement = null;
|
||||
wireBlurValidation(makeContainer([input]));
|
||||
assert.doesNotThrow(() => input._listeners['blur']());
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// btnSuccess
|
||||
// --------------------------------------------------------
|
||||
|
||||
test('btnSuccess: fügt btn--success-Klasse hinzu', () => {
|
||||
global.setTimeout = () => {};
|
||||
const btn = makeBtn();
|
||||
btnSuccess(btn, 'Test');
|
||||
assert.ok(btn._classes.has('btn--success'));
|
||||
global.setTimeout = _origSetTimeout;
|
||||
});
|
||||
|
||||
test('btnSuccess: setzt SVG-Checkmark als innerHTML', () => {
|
||||
global.setTimeout = () => {};
|
||||
const btn = makeBtn();
|
||||
btnSuccess(btn, 'Test');
|
||||
assert.ok(btn.innerHTML.includes('<svg'));
|
||||
assert.ok(btn.innerHTML.includes('polyline'));
|
||||
global.setTimeout = _origSetTimeout;
|
||||
});
|
||||
|
||||
test('btnSuccess: stellt Label nach 700ms wieder her', () => {
|
||||
let capturedFn, capturedMs;
|
||||
global.setTimeout = (fn, ms) => { capturedFn = fn; capturedMs = ms; };
|
||||
const btn = makeBtn({ textContent: 'Speichern' });
|
||||
btnSuccess(btn, 'Speichern');
|
||||
assert.equal(capturedMs, 700);
|
||||
capturedFn();
|
||||
assert.ok(!btn._classes.has('btn--success'));
|
||||
assert.equal(btn.textContent, 'Speichern');
|
||||
global.setTimeout = _origSetTimeout;
|
||||
});
|
||||
|
||||
test('btnSuccess: nutzt btn.textContent als Fallback wenn kein Label übergeben', () => {
|
||||
let capturedFn;
|
||||
global.setTimeout = (fn) => { capturedFn = fn; };
|
||||
const btn = makeBtn({ textContent: 'Automatisch' });
|
||||
btnSuccess(btn);
|
||||
capturedFn();
|
||||
assert.equal(btn.textContent, 'Automatisch');
|
||||
global.setTimeout = _origSetTimeout;
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// btnError
|
||||
// --------------------------------------------------------
|
||||
|
||||
test('btnError: fügt btn--shaking-Klasse hinzu', () => {
|
||||
const btn = makeBtn();
|
||||
btnError(btn);
|
||||
assert.ok(btn._classes.has('btn--shaking'));
|
||||
});
|
||||
|
||||
test('btnError: entfernt btn--shaking nach animationend', () => {
|
||||
const btn = makeBtn();
|
||||
btnError(btn);
|
||||
btn._listeners['animationend']();
|
||||
assert.ok(!btn._classes.has('btn--shaking'));
|
||||
});
|
||||
|
||||
test('btnError: entfernt btn--shaking zuerst um Animation-Restart zu erzwingen', () => {
|
||||
const order = [];
|
||||
const btn = makeBtn();
|
||||
const origAdd = btn.classList.add.bind(btn);
|
||||
const origRemove = btn.classList.remove.bind(btn);
|
||||
btn.classList.remove = (cls) => { order.push(`remove:${cls}`); origRemove(cls); };
|
||||
btn.classList.add = (cls) => { order.push(`add:${cls}`); origAdd(cls); };
|
||||
btnError(btn);
|
||||
assert.equal(order[0], 'remove:btn--shaking');
|
||||
assert.equal(order[1], 'add:btn--shaking');
|
||||
});
|
||||
Reference in New Issue
Block a user