Skip to content

Commit f95856b

Browse files
committed
chore: refactor testing script
1 parent d6f1ddb commit f95856b

File tree

1 file changed

+83
-72
lines changed

1 file changed

+83
-72
lines changed

scripts/test-a11y-unified.mjs

Lines changed: 83 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -185,97 +185,108 @@ function checkLabelAssociation(el, document) {
185185
return { matched: false };
186186
}
187187

188-
function isIdReferencePattern(attrName, expectedValue) {
189-
return ID_REFERENCE_ATTRS.includes(attrName) && expectedValue &&
190-
(expectedValue.includes(' id') || expectedValue.startsWith('.k-'));
191-
}
192-
193-
function validateIdReference(el, attrName, expectedValue, document) {
194-
const actualValue = el.getAttribute(attrName);
195-
if (!actualValue?.trim()) {
196-
return { matched: false, reason: 'missing-id-reference', expected: `ID reference (${expectedValue})`, actual: actualValue };
197-
}
188+
// ── Strategy validators ─────────────────────────────────────────────
189+
// Each returns { matched, actual?, expected?, reason? }
198190

199-
const targetSelector = expectedValue.replace(/\s+id$/, '').trim();
200-
const targetEl = document.getElementById(actualValue);
201-
if (!targetEl) {
202-
return { matched: false, reason: 'id-not-found', expected: `element with id="${actualValue}"`, actual: 'no element found' };
203-
}
204-
205-
if (targetSelector?.startsWith('.k-')) {
206-
const isListboxSel = targetSelector === '.k-list-ul';
207-
if (!targetEl.matches(targetSelector) && !(isListboxSel && targetEl.matches('.k-list-content[role="listbox"]'))) {
208-
return { matched: false, reason: 'id-wrong-element', expected: `${targetSelector} with id="${actualValue}"`, actual: targetEl.className };
209-
}
210-
}
211-
return { matched: true, actual: actualValue, reason: 'id-reference-valid' };
212-
}
213-
214-
/**
215-
* Check a single attribute spec against a DOM element.
216-
* Handles: boolean attrs, ID references, "or" alternatives, label associations,
217-
* nodeName checks, and simple presence/value checks.
218-
*/
219-
function checkSingleAttr(el, attrSpec, document) {
220-
if (attrSpec === 'label for') {
221-
return checkLabelAssociation(el, document);
222-
}
223-
if (attrSpec.startsWith('nodeName=')) {
224-
return { matched: el.nodeName.toUpperCase() === attrSpec.split('=')[1].toUpperCase() };
225-
}
191+
const attrValidators = {
192+
labelFor: (el, _attr, _val, doc) =>
193+
checkLabelAssociation(el, doc),
226194

227-
const m = attrSpec.match(/^(\S+?)(?:=(.+))?$/);
228-
if (!m) { return { matched: false }; }
195+
nodeName: (el, _attr, tag) =>
196+
({ matched: el.nodeName.toUpperCase() === tag.toUpperCase() }),
229197

230-
const [, attr, rawVal] = m;
231-
// Strip surrounding quotes from value (e.g. role="switch" → switch)
232-
const val = rawVal?.replace(/^["']|["']$/g, '');
233-
if (BOOLEAN_ATTRS.includes(attr)) {
198+
boolean: (el, attr) => {
234199
const present = el.getAttribute(attr) !== null;
235200
return { matched: present, actual: present ? 'present' : null, expected: 'present' };
236-
}
237-
if (isIdReferencePattern(attr, val)) {
238-
return validateIdReference(el, attr, val, document);
239-
}
201+
},
240202

241-
// Role check — account for implicit roles from semantic HTML
242-
if (attr === 'role' && val) {
203+
idReference: (el, attr, val, doc) => {
204+
const actual = el.getAttribute(attr);
205+
if (!actual?.trim()) {
206+
return { matched: false, reason: 'missing-id-reference', expected: `ID reference (${val})`, actual };
207+
}
208+
const targetSelector = val.replace(/\s+id$/, '').trim();
209+
const targetEl = doc.getElementById(actual);
210+
if (!targetEl) {
211+
return { matched: false, reason: 'id-not-found', expected: `element with id="${actual}"`, actual: 'no element found' };
212+
}
213+
if (targetSelector?.startsWith('.k-')) {
214+
const isListboxSel = targetSelector === '.k-list-ul';
215+
if (!targetEl.matches(targetSelector) && !(isListboxSel && targetEl.matches('.k-list-content[role="listbox"]'))) {
216+
return { matched: false, reason: 'id-wrong-element', expected: `${targetSelector} with id="${actual}"`, actual: targetEl.className };
217+
}
218+
}
219+
return { matched: true, actual, reason: 'id-reference-valid' };
220+
},
221+
222+
role: (el, _attr, val) => {
243223
const explicit = el.getAttribute('role');
244224
if (explicit === val) { return { matched: true, actual: explicit }; }
245225
const implicit = getImplicitRole(el);
246226
if (implicit === val) { return { matched: true, actual: `${val} (implicit from <${el.nodeName.toLowerCase()}>)` }; }
247227
return { matched: false, actual: explicit || implicit || null, expected: val };
248-
}
249-
250-
// tabindex — natively focusable elements don't need explicit tabindex="0"
251-
if (attr === 'tabindex' && val === '0' && NATIVE_FOCUSABLE.includes(el.nodeName)) {
252-
return { matched: true, actual: '0 (natively focusable)', reason: 'native-focusable' };
253-
}
228+
},
254229

255-
const actual = el.getAttribute(attr);
230+
nativeFocusable: () =>
231+
({ matched: true, actual: '0 (natively focusable)', reason: 'native-focusable' }),
256232

257-
// Value with alternatives: "true/false" or "list|both|inline"
258-
// If the attribute is absent, the rule passes (value constraint only applies when present)
259-
if (val && (val.includes('/') || val.includes('|'))) {
233+
multiValue: (el, attr, val) => {
260234
const alternatives = val.split(/[/|]/).map(v => v.trim());
235+
const actual = el.getAttribute(attr);
261236
if (actual === null) { return { matched: true, actual: null, reason: 'optional-value-absent' }; }
262-
const matched = alternatives.includes(actual);
263-
return { matched, actual, expected: alternatives.join(' or ') };
264-
}
237+
return { matched: alternatives.includes(actual), actual, expected: alternatives.join(' or ') };
238+
},
265239

266-
// Template variable pattern: ${id}-something → match any non-empty value
267-
if (val && val.includes('${')) {
268-
const matched = actual !== null && actual.trim() !== '';
269-
return { matched, actual, expected: `pattern: ${val}` };
270-
}
240+
templatePattern: (el, attr, val) => {
241+
const actual = el.getAttribute(attr);
242+
return { matched: actual !== null && actual.trim() !== '', actual, expected: `pattern: ${val}` };
243+
},
244+
245+
stateDependentAbsent: () =>
246+
({ matched: true, actual: null, reason: 'state-dependent-absent' }),
271247

272-
// State-dependent attributes: presence-only check (no =value) passes when absent
273-
if (!val && STATE_DEPENDENT_ATTRS.includes(attr) && actual === null) {
274-
return { matched: true, actual: null, reason: 'state-dependent-absent' };
248+
exactValue: (el, attr, val) => {
249+
const actual = el.getAttribute(attr);
250+
return { matched: actual === val, actual, expected: val };
251+
},
252+
253+
presence: (el, attr) => {
254+
const actual = el.getAttribute(attr);
255+
return { matched: actual !== null, actual, expected: 'present' };
275256
}
257+
};
258+
259+
// ── Classifier ──────────────────────────────────────────────────────
260+
// Maps an attribute spec string + element context to a strategy key + parsed parts.
261+
262+
function classifyAttr(attrSpec, el) {
263+
if (attrSpec === 'label for') { return { type: 'labelFor' }; }
264+
if (attrSpec.startsWith('nodeName=')) { return { type: 'nodeName', val: attrSpec.split('=')[1] }; }
276265

277-
const matched = val ? actual === val : actual !== null;
278-
return { matched, actual, expected: val || 'present' };
266+
const m = attrSpec.match(/^(\S+?)(?:=(.+))?$/);
267+
if (!m) { return null; }
268+
269+
const attr = m[1];
270+
const val = m[2]?.replace(/^["']|["']$/g, '');
271+
272+
if (BOOLEAN_ATTRS.includes(attr)) { return { type: 'boolean', attr }; }
273+
if (ID_REFERENCE_ATTRS.includes(attr) && val && (val.includes(' id') || val.startsWith('.k-'))) { return { type: 'idReference', attr, val }; }
274+
if (attr === 'role' && val) { return { type: 'role', attr, val }; }
275+
if (attr === 'tabindex' && val === '0' && NATIVE_FOCUSABLE.includes(el.nodeName)) { return { type: 'nativeFocusable', attr, val }; }
276+
if (val && (val.includes('/') || val.includes('|'))) { return { type: 'multiValue', attr, val }; }
277+
if (val && val.includes('${')) { return { type: 'templatePattern', attr, val }; }
278+
if (!val && STATE_DEPENDENT_ATTRS.includes(attr) && el.getAttribute(attr) === null) { return { type: 'stateDependentAbsent', attr }; }
279+
if (val) { return { type: 'exactValue', attr, val }; }
280+
return { type: 'presence', attr };
281+
}
282+
283+
// ── Single attribute check (delegates to strategy) ──────────────────
284+
285+
function checkSingleAttr(el, attrSpec, document) {
286+
const classified = classifyAttr(attrSpec, el);
287+
if (!classified) { return { matched: false }; }
288+
const { type, attr, val } = classified;
289+
return attrValidators[type](el, attr, val, document);
279290
}
280291

281292
function checkAttributeRule(el, attribute, document) {

0 commit comments

Comments
 (0)