@birhaus/test-utils
Version:
BIRHAUS v3.0 Radical Minimalist Testing Framework - Glass morphism validators, generous spacing tests, and v3 component validation utilities
1,333 lines (1,328 loc) • 118 kB
JavaScript
import { toHaveNoViolations, axe } from 'jest-axe';
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// src/cognitive/MillersLawValidator.ts
var MillersLawValidator = class {
constructor(options = {}) {
this.options = {
maxNavigationItems: 7,
maxFormFields: 7,
maxSelectOptions: 7,
maxTabs: 4,
maxActions: 4,
strict: false,
includeDisabled: false,
checkHidden: false,
navigationSelector: 'nav, [role="navigation"], [data-birhaus-component="navigation"]',
formSelector: 'form, [data-birhaus-component="form"]',
selectSelector: 'select, [data-birhaus-component="select"]',
tabSelector: '[role="tablist"], [data-birhaus-component="tabs"]',
actionSelector: 'button, [role="button"], a[href], [data-birhaus-component="button"]',
...options
};
}
/**
* Validate all Miller's Law compliance issues in the current DOM
*/
validateAll(container = document.body) {
const violations = [];
violations.push(...this.validateNavigation(container));
violations.push(...this.validateForms(container));
violations.push(...this.validateSelects(container));
violations.push(...this.validateTabs(container));
violations.push(...this.validateActions(container));
return violations;
}
/**
* Validate navigation items (BIRHAUS Principle #1)
*/
validateNavigation(container = document.body) {
const violations = [];
const navElements = container.querySelectorAll(this.options.navigationSelector);
navElements.forEach((nav) => {
const items = this.getNavigationItems(nav);
if (items.length > this.options.maxNavigationItems) {
const severity = this.getSeverity(items.length, this.options.maxNavigationItems);
violations.push({
type: "navigation",
element: nav,
count: items.length,
maxRecommended: this.options.maxNavigationItems,
severity,
message: `Navigation has ${items.length} items (Miller's Law recommends max ${this.options.maxNavigationItems})`,
messageEs: `Navegaci\xF3n tiene ${items.length} elementos (Ley de Miller recomienda m\xE1ximo ${this.options.maxNavigationItems})`,
birhausPrinciple: 1,
suggestions: [
"Group related navigation items into dropdown menus",
"Use progressive disclosure to hide secondary navigation",
"Consider using breadcrumbs for deep navigation",
"Implement search functionality for large menus"
]
});
}
});
return violations;
}
/**
* Validate form field counts (BIRHAUS Principle #4)
*/
validateForms(container = document.body) {
const violations = [];
const formElements = container.querySelectorAll(this.options.formSelector);
formElements.forEach((form) => {
const fields = this.getFormFields(form);
if (fields.length > this.options.maxFormFields) {
const severity = this.getSeverity(fields.length, this.options.maxFormFields);
violations.push({
type: "form",
element: form,
count: fields.length,
maxRecommended: this.options.maxFormFields,
severity,
message: `Form has ${fields.length} fields (Miller's Law recommends max ${this.options.maxFormFields})`,
messageEs: `Formulario tiene ${fields.length} campos (Ley de Miller recomienda m\xE1ximo ${this.options.maxFormFields})`,
birhausPrinciple: 4,
suggestions: [
"Split form into multiple steps using BirhausForm",
"Use progressive disclosure to reveal fields conditionally",
"Group related fields into collapsible sections",
"Consider using a wizard pattern for complex forms"
]
});
}
});
return violations;
}
/**
* Validate select option counts
*/
validateSelects(container = document.body) {
const violations = [];
const selectElements = container.querySelectorAll(this.options.selectSelector);
selectElements.forEach((select) => {
const options = this.getSelectOptions(select);
if (options.length > this.options.maxSelectOptions) {
const severity = this.getSeverity(options.length, this.options.maxSelectOptions);
violations.push({
type: "select",
element: select,
count: options.length,
maxRecommended: this.options.maxSelectOptions,
severity,
message: `Select has ${options.length} options (Miller's Law recommends max ${this.options.maxSelectOptions})`,
messageEs: `Lista desplegable tiene ${options.length} opciones (Ley de Miller recomienda m\xE1ximo ${this.options.maxSelectOptions})`,
birhausPrinciple: 1,
suggestions: [
"Use BirhausCombobox with search functionality",
"Group options into categories",
"Implement progressive filtering",
"Consider using autocomplete instead of select"
]
});
}
});
return violations;
}
/**
* Validate tab counts (BIRHAUS 4-3-1 Rule)
*/
validateTabs(container = document.body) {
const violations = [];
const tabElements = container.querySelectorAll(this.options.tabSelector);
tabElements.forEach((tabContainer) => {
const tabs = this.getTabs(tabContainer);
if (tabs.length > this.options.maxTabs) {
const severity = this.getSeverity(tabs.length, this.options.maxTabs);
violations.push({
type: "tabs",
element: tabContainer,
count: tabs.length,
maxRecommended: this.options.maxTabs,
severity,
message: `Tabs have ${tabs.length} items (BIRHAUS 4-3-1 rule recommends max ${this.options.maxTabs})`,
messageEs: `Pesta\xF1as tienen ${tabs.length} elementos (regla BIRHAUS 4-3-1 recomienda m\xE1ximo ${this.options.maxTabs})`,
birhausPrinciple: 1,
suggestions: [
"Use BirhausTabs with overflow dropdown",
"Group related tabs into sections",
"Consider using accordion instead of tabs",
"Implement nested navigation structure"
]
});
}
});
return violations;
}
/**
* Validate action button counts
*/
validateActions(container = document.body) {
const violations = [];
const containers = container.querySelectorAll('form, [role="dialog"], [data-birhaus-component="card"], .actions');
containers.forEach((actionContainer) => {
const actions = this.getActions(actionContainer);
if (actions.length > this.options.maxActions) {
const severity = this.getSeverity(actions.length, this.options.maxActions);
violations.push({
type: "actions",
element: actionContainer,
count: actions.length,
maxRecommended: this.options.maxActions,
severity,
message: `Container has ${actions.length} actions (BIRHAUS 4-3-1 rule recommends max ${this.options.maxActions})`,
messageEs: `Contenedor tiene ${actions.length} acciones (regla BIRHAUS 4-3-1 recomienda m\xE1ximo ${this.options.maxActions})`,
birhausPrinciple: 3,
suggestions: [
"Combine related actions into dropdown menus",
"Use progressive disclosure for secondary actions",
"Implement one clear primary action pattern",
"Move less important actions to overflow menu"
]
});
}
});
return violations;
}
/**
* Get navigation items from a navigation element
*/
getNavigationItems(nav) {
const items = nav.querySelectorAll('a, button, [role="menuitem"], [role="link"]');
return this.filterElements(Array.from(items));
}
/**
* Get form fields from a form element
*/
getFormFields(form) {
const fields = form.querySelectorAll('input, select, textarea, [role="textbox"], [role="combobox"], [role="listbox"]');
return this.filterElements(Array.from(fields));
}
/**
* Get options from a select element
*/
getSelectOptions(select) {
if (select.tagName.toLowerCase() === "select") {
const options2 = select.querySelectorAll("option");
return this.filterElements(Array.from(options2));
}
const options = select.querySelectorAll('[role="option"], [data-value]');
return this.filterElements(Array.from(options));
}
/**
* Get tabs from a tab container
*/
getTabs(tabContainer) {
const tabs = tabContainer.querySelectorAll('[role="tab"], [data-birhaus-component="tab"]');
return this.filterElements(Array.from(tabs));
}
/**
* Get action buttons from a container
*/
getActions(container) {
const actions = container.querySelectorAll(this.options.actionSelector);
return this.filterElements(Array.from(actions));
}
/**
* Filter elements based on options (hidden, disabled, etc.)
*/
filterElements(elements) {
return elements.filter((element) => {
if (!this.options.includeDisabled && element.hasAttribute("disabled")) {
return false;
}
if (!this.options.checkHidden) {
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
}
return true;
});
}
/**
* Calculate severity based on how much the count exceeds the limit
*/
getSeverity(count, maxRecommended) {
const excess = count - maxRecommended;
const excessPercentage = excess / maxRecommended * 100;
if (excessPercentage > 100) return "critical";
if (excessPercentage > 50) return "high";
if (excessPercentage > 25) return "medium";
return "low";
}
};
function expectNavigationCompliance(container, maxItems = 7) {
const validator = new MillersLawValidator({ maxNavigationItems: maxItems });
const violations = validator.validateNavigation(container);
if (violations.length > 0) {
const violation = violations[0];
throw new Error(
`Miller's Law violation: ${violation.message}
Suggestions: ${violation.suggestions.join(", ")}`
);
}
}
function expectFormCompliance(container, maxFields = 7) {
const validator = new MillersLawValidator({ maxFormFields: maxFields });
const violations = validator.validateForms(container);
if (violations.length > 0) {
const violation = violations[0];
throw new Error(
`Miller's Law violation: ${violation.message}
Suggestions: ${violation.suggestions.join(", ")}`
);
}
}
function expectTabCompliance(container, maxTabs = 4) {
const validator = new MillersLawValidator({ maxTabs });
const violations = validator.validateTabs(container);
if (violations.length > 0) {
const violation = violations[0];
throw new Error(
`BIRHAUS 4-3-1 Rule violation: ${violation.message}
Suggestions: ${violation.suggestions.join(", ")}`
);
}
}
function expectMillersLawCompliance(container, options) {
const validator = new MillersLawValidator(options);
const violations = validator.validateAll(container);
if (violations.length > 0) {
const criticalViolations = violations.filter((v) => v.severity === "critical");
const highViolations = violations.filter((v) => v.severity === "high");
if (criticalViolations.length > 0 || highViolations.length > 0) {
const errorMessages = [...criticalViolations, ...highViolations].map((v) => `${v.type}: ${v.message}`).join("\n");
throw new Error(
`Miller's Law violations detected:
${errorMessages}
Total violations: ${violations.length} (${criticalViolations.length} critical, ${highViolations.length} high)`
);
}
}
}
expect.extend(toHaveNoViolations);
var AccessibilityValidator = class {
constructor(options = {}) {
this.options = {
level: "AA",
includeColorContrast: true,
includeKeyboardNav: true,
includeFocusManagement: true,
includeAriaLabels: true,
includeSpanishLabels: true,
customRules: {},
interactiveElements: 'button, a, input, select, textarea, [role="button"], [role="link"], [tabindex]',
spanishElements: '[data-label-es], [aria-label*="es"], [lang="es"]',
...options
};
}
/**
* Run comprehensive accessibility validation
*/
async validateAll(container = document.body) {
const violations = [];
const axeViolations = await this.validateWithAxe(container);
violations.push(...axeViolations);
if (this.options.includeColorContrast) {
violations.push(...this.validateColorContrast(container));
}
if (this.options.includeKeyboardNav) {
violations.push(...await this.validateKeyboardNavigation(container));
}
if (this.options.includeFocusManagement) {
violations.push(...this.validateFocusManagement(container));
}
if (this.options.includeAriaLabels) {
violations.push(...this.validateAriaLabels(container));
}
if (this.options.includeSpanishLabels) {
violations.push(...this.validateSpanishLabels(container));
}
return violations;
}
/**
* Validate using axe-core accessibility engine
*/
async validateWithAxe(container) {
const violations = [];
try {
const results = await axe(container, {
rules: {
// Enable specific WCAG rules based on level
"color-contrast": { enabled: this.options.level !== "A" },
"color-contrast-enhanced": { enabled: this.options.level === "AAA" },
...this.options.customRules
}
});
results.violations.forEach((violation) => {
violation.nodes.forEach((node) => {
const element = document.querySelector(node.target[0]);
if (element) {
violations.push({
type: this.getViolationType(violation.id),
element,
severity: violation.impact || "moderate",
message: violation.description,
messageEs: this.translateViolation(violation.description),
wcagLevel: this.getWcagLevel(violation.tags),
wcagCriterion: violation.id,
suggestions: violation.nodes[0]?.failureSummary ? [violation.nodes[0].failureSummary] : [],
birhausPrinciple: 6
});
}
});
});
} catch (error) {
console.error("Axe accessibility test failed:", error);
}
return violations;
}
/**
* Validate color contrast ratios (WCAG AA+ requirement)
*/
validateColorContrast(container) {
const violations = [];
const elements = container.querySelectorAll("*");
elements.forEach((element) => {
const style = getComputedStyle(element);
const textColor = style.color;
const backgroundColor = style.backgroundColor;
if (textColor && backgroundColor && this.hasText(element)) {
const contrast = this.calculateContrastRatio(textColor, backgroundColor);
const requiredContrast = this.getRequiredContrast(element);
if (contrast < requiredContrast) {
violations.push({
type: "contrast",
element,
severity: contrast < requiredContrast * 0.7 ? "serious" : "moderate",
message: `Color contrast ratio ${contrast.toFixed(2)}:1 is below WCAG ${this.options.level} requirement of ${requiredContrast}:1`,
messageEs: `Contraste de color ${contrast.toFixed(2)}:1 est\xE1 por debajo del requisito WCAG ${this.options.level} de ${requiredContrast}:1`,
wcagLevel: this.options.level,
wcagCriterion: "color-contrast",
suggestions: [
"Increase color contrast between text and background",
"Use darker text colors or lighter background colors",
"Test with BIRHAUS financial theme for compliant colors",
"Consider using semantic color tokens from theme"
],
birhausPrinciple: 6
});
}
}
});
return violations;
}
/**
* Validate keyboard navigation (WCAG requirement)
*/
// eslint-disable-next-line max-lines-per-function -- Keyboard navigation validation requires comprehensive testing
async validateKeyboardNavigation(container) {
const violations = [];
const interactiveElements = container.querySelectorAll(this.options.interactiveElements);
for (const element of Array.from(interactiveElements)) {
try {
if (!this.isFocusable(element)) {
violations.push({
type: "keyboard",
element,
severity: "serious",
message: "Interactive element cannot be focused with keyboard",
messageEs: "Elemento interactivo no puede recibir foco con teclado",
wcagLevel: "A",
wcagCriterion: "keyboard-accessible",
suggestions: [
'Add tabindex="0" to make element focusable',
"Ensure element is not disabled when it should be interactive",
"Use semantic HTML elements (button, a, input) instead of divs",
"Add proper ARIA roles for custom interactive elements"
],
birhausPrinciple: 6
});
}
element.focus();
const focusedStyle = getComputedStyle(element);
const hasFocusIndicator = this.hasFocusIndicator(element, focusedStyle);
if (!hasFocusIndicator) {
violations.push({
type: "focus",
element,
severity: "moderate",
message: "Element lacks visible focus indicator",
messageEs: "Elemento carece de indicador visual de foco",
wcagLevel: "AA",
wcagCriterion: "focus-visible",
suggestions: [
"Add focus:ring-2 focus:ring-blue-500 classes",
"Use BIRHAUS focus styles from design tokens",
"Ensure focus outline is visible against background",
"Test focus indicators with high contrast mode"
],
birhausPrinciple: 6
});
}
} catch (error) {
console.warn("Keyboard navigation test failed for element:", element);
}
}
return violations;
}
/**
* Validate focus management
*/
validateFocusManagement(container) {
const violations = [];
const dialogs = container.querySelectorAll('[role="dialog"], [role="alertdialog"], [data-birhaus-component="dialog"]');
dialogs.forEach((dialog) => {
const focusableElements = this.getFocusableElements(dialog);
if (focusableElements.length === 0) {
violations.push({
type: "focus",
element: dialog,
severity: "serious",
message: "Dialog contains no focusable elements",
messageEs: "Di\xE1logo no contiene elementos que puedan recibir foco",
wcagLevel: "A",
wcagCriterion: "focus-management",
suggestions: [
"Add at least one focusable element (button, input, etc.)",
"Use BirhausDialog which includes proper focus management",
"Ensure close button is focusable",
'Add tabindex="-1" to dialog container if needed'
],
birhausPrinciple: 6
});
}
});
return violations;
}
/**
* Validate ARIA labels and roles
*/
validateAriaLabels(container) {
const violations = [];
const interactiveElements = container.querySelectorAll(this.options.interactiveElements);
interactiveElements.forEach((element) => {
const hasAccessibleName = this.hasAccessibleName(element);
if (!hasAccessibleName) {
violations.push({
type: "aria",
element,
severity: "serious",
message: "Interactive element lacks accessible name",
messageEs: "Elemento interactivo carece de nombre accesible",
wcagLevel: "A",
wcagCriterion: "name-role-value",
suggestions: [
"Add aria-label attribute with descriptive text",
"Add aria-labelledby pointing to a label element",
"Use semantic HTML with proper text content",
"For BIRHAUS components, use labelEs and labelEn props"
],
birhausPrinciple: 6
});
}
});
return violations;
}
/**
* Validate Spanish-first accessibility (BIRHAUS Principle #7)
*/
validateSpanishLabels(container) {
const violations = [];
const birhausComponents = container.querySelectorAll("[data-birhaus-component]");
birhausComponents.forEach((element) => {
const hasSpanishLabel = this.hasSpanishAccessibilityLabel(element);
if (!hasSpanishLabel) {
violations.push({
type: "spanish",
element,
severity: "moderate",
message: "BIRHAUS component lacks Spanish accessibility labels",
messageEs: "Componente BIRHAUS carece de etiquetas de accesibilidad en espa\xF1ol",
wcagLevel: "AA",
wcagCriterion: "language-identification",
suggestions: [
"Add labelEs prop for Spanish labels",
"Include aria-label in Spanish",
"Use data-label-es attribute",
'Set lang="es" for Spanish content sections'
],
birhausPrinciple: 7
});
}
});
return violations;
}
// Helper methods
getViolationType(ruleId) {
if (ruleId.includes("contrast")) return "contrast";
if (ruleId.includes("keyboard") || ruleId.includes("tabindex")) return "keyboard";
if (ruleId.includes("aria") || ruleId.includes("label")) return "aria";
if (ruleId.includes("focus")) return "focus";
return "structure";
}
getWcagLevel(tags) {
if (tags.includes("wcag2aaa")) return "AAA";
if (tags.includes("wcag2aa")) return "AA";
return "A";
}
translateViolation(description) {
const translations = {
"Elements must have sufficient color contrast": "Los elementos deben tener suficiente contraste de color",
"All interactive elements must have an accessible name": "Todos los elementos interactivos deben tener un nombre accesible",
"Elements must have keyboard access": "Los elementos deben tener acceso por teclado",
"Form elements must have labels": "Los elementos de formulario deben tener etiquetas"
};
return translations[description] || description;
}
hasText(element) {
const textContent = element.textContent?.trim();
return textContent !== null && textContent !== void 0 && textContent.length > 0;
}
calculateContrastRatio(color1, color2) {
const rgb1 = this.parseColor(color1);
const rgb2 = this.parseColor(color2);
const l1 = this.getRelativeLuminance(rgb1);
const l2 = this.getRelativeLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
parseColor(color) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (match) {
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
}
return [0, 0, 0];
}
getRelativeLuminance([r, g, b]) {
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
getRequiredContrast(element) {
const style = getComputedStyle(element);
const fontSize = parseFloat(style.fontSize);
const fontWeight = style.fontWeight;
const isLargeText = fontSize >= 18 || fontSize >= 14 && (fontWeight === "bold" || parseInt(fontWeight) >= 700);
return this.options.level === "AAA" ? isLargeText ? 4.5 : 7 : isLargeText ? 3 : 4.5;
}
isFocusable(element) {
const tabIndex = element.getAttribute("tabindex");
const isDisabled = element.hasAttribute("disabled");
const tagName = element.tagName.toLowerCase();
if (isDisabled) return false;
if (tabIndex === "-1") return false;
if (tabIndex && parseInt(tabIndex) >= 0) return true;
const focusableTags = ["a", "button", "input", "select", "textarea"];
return focusableTags.includes(tagName);
}
hasFocusIndicator(element, style) {
return !!(style.outline !== "none" || style.outlineWidth !== "0px" || style.boxShadow !== "none" || style.border !== style.borderWidth);
}
getFocusableElements(container) {
const focusableSelectors = [
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"a[href]",
'[tabindex]:not([tabindex="-1"])'
].join(", ");
return Array.from(container.querySelectorAll(focusableSelectors));
}
hasAccessibleName(element) {
const ariaLabel = element.getAttribute("aria-label");
const ariaLabelledBy = element.getAttribute("aria-labelledby");
const textContent = element.textContent?.trim();
return !!(ariaLabel || ariaLabelledBy || textContent);
}
hasSpanishAccessibilityLabel(element) {
const ariaLabel = element.getAttribute("aria-label");
const dataLabelEs = element.getAttribute("data-label-es");
const lang = element.getAttribute("lang");
const hasSpanishPatterns = ariaLabel && /[ñáéíóúü]|ción|dad|mente/i.test(ariaLabel);
return !!(dataLabelEs || hasSpanishPatterns || lang === "es");
}
};
async function expectAccessibilityCompliance(container, level = "AA") {
const validator = new AccessibilityValidator({ level });
const violations = await validator.validateAll(container);
const criticalViolations = violations.filter((v) => v.severity === "serious" || v.severity === "critical");
if (criticalViolations.length > 0) {
const errorMessages = criticalViolations.map((v) => `${v.type}: ${v.message}`).join("\n");
throw new Error(
`WCAG ${level} violations detected:
${errorMessages}
Total violations: ${violations.length} (${criticalViolations.length} critical/serious)`
);
}
}
async function expectSpanishAccessibility(container) {
const validator = new AccessibilityValidator({ includeSpanishLabels: true });
const violations = await validator.validateAll(container);
const spanishViolations = violations.filter((v) => v.type === "spanish");
if (spanishViolations.length > 0) {
const errorMessages = spanishViolations.map((v) => v.messageEs).join("\n");
throw new Error(
`Spanish accessibility violations detected:
${errorMessages}`
);
}
}
async function expectKeyboardAccessible(container) {
const validator = new AccessibilityValidator({ includeKeyboardNav: true });
const violations = await validator.validateKeyboardNavigation(container || document.body);
if (violations.length > 0) {
const errorMessages = violations.map((v) => v.message).join("\n");
throw new Error(
`Keyboard accessibility violations detected:
${errorMessages}`
);
}
}
// src/spanish/SpanishCoverageValidator.ts
var SpanishCoverageValidator = class {
constructor(options = {}) {
// Paraguay-specific financial terminology
this.financialTerms = {
// Currency and amounts
"amount": { es: "monto", alternatives: ["cantidad", "importe"] },
"balance": { es: "saldo", alternatives: ["balance"] },
"income": { es: "ingreso", alternatives: ["entrada"] },
"expense": { es: "gasto", alternatives: ["egreso"] },
"transfer": { es: "transferencia", alternatives: [] },
"payment": { es: "pago", alternatives: [] },
"receipt": { es: "recibo", alternatives: ["comprobante"] },
"invoice": { es: "factura", alternatives: [] },
"tax": { es: "impuesto", alternatives: [] },
"total": { es: "total", alternatives: [] },
"subtotal": { es: "subtotal", alternatives: [] },
// Status terms
"pending": { es: "pendiente", alternatives: ["en espera"] },
"approved": { es: "aprobado", alternatives: ["autorizado"] },
"rejected": { es: "rechazado", alternatives: ["denegado"] },
"completed": { es: "completado", alternatives: ["finalizado"] },
"cancelled": { es: "cancelado", alternatives: ["anulado"] },
// Actions
"save": { es: "guardar", alternatives: [] },
"cancel": { es: "cancelar", alternatives: [] },
"edit": { es: "editar", alternatives: ["modificar"] },
"delete": { es: "eliminar", alternatives: ["borrar"] },
"approve": { es: "aprobar", alternatives: ["autorizar"] },
"reject": { es: "rechazar", alternatives: ["denegar"] },
"export": { es: "exportar", alternatives: [] },
"print": { es: "imprimir", alternatives: [] },
// Church-specific terms
"donation": { es: "donaci\xF3n", alternatives: ["ofrenda"] },
"tithe": { es: "diezmo", alternatives: [] },
"offering": { es: "ofrenda", alternatives: [] },
"church": { es: "iglesia", alternatives: [] },
"pastor": { es: "pastor", alternatives: [] },
"member": { es: "miembro", alternatives: [] },
"treasurer": { es: "tesorero", alternatives: [] },
"administrator": { es: "administrador", alternatives: [] }
};
// Common Spanish patterns for Paraguay
this.spanishPatterns = [
/[ñáéíóúü]/i,
// Spanish characters
/ción\b/i,
// -ción endings
/dad\b/i,
// -dad endings
/mente\b/i,
// -mente adverbs
/\bel\b|\bla\b|\blos\b|\blas\b/i,
// Articles
/\ben\b|\bde\b|\bdel\b|\bal\b/i,
// Prepositions
/\by\b|\bo\b|\bpero\b|\bsi\b/i
// Conjunctions
];
this.options = {
requireSpanishPrimary: true,
requireEnglishFallback: true,
strictFinancialTerms: true,
validateParaguayanSpanish: true,
validateFinancialVocabulary: true,
validateCurrencyFormats: true,
checkBirhausComponents: true,
checkErrorMessages: true,
checkButtonLabels: true,
checkFormLabels: true,
birhausSelector: "[data-birhaus-component]",
interactiveSelector: 'button, a, input, select, textarea, [role="button"]',
contentSelector: 'h1, h2, h3, h4, h5, h6, p, span, div[class*="text"], label',
...options
};
}
/**
* Validate all Spanish coverage requirements
*/
validateAll(container = document.body) {
const violations = [];
if (this.options.checkBirhausComponents) {
violations.push(...this.validateBirhausComponents(container));
}
if (this.options.checkButtonLabels) {
violations.push(...this.validateButtonLabels(container));
}
if (this.options.checkFormLabels) {
violations.push(...this.validateFormLabels(container));
}
if (this.options.checkErrorMessages) {
violations.push(...this.validateErrorMessages(container));
}
if (this.options.validateFinancialVocabulary) {
violations.push(...this.validateFinancialTerminology(container));
}
if (this.options.validateCurrencyFormats) {
violations.push(...this.validateCurrencyFormats(container));
}
return violations;
}
/**
* Validate BIRHAUS components have Spanish-first labels
*/
validateBirhausComponents(container) {
const violations = [];
const birhausComponents = container.querySelectorAll(this.options.birhausSelector);
birhausComponents.forEach((component) => {
const componentType = component.getAttribute("data-birhaus-component");
const hasSpanishLabel = this.checkSpanishLabels(component);
const hasEnglishFallback = this.checkEnglishFallbacks(component);
if (this.options.requireSpanishPrimary && !hasSpanishLabel) {
violations.push({
type: "missing-spanish",
element: component,
severity: "serious",
message: `BIRHAUS ${componentType} component missing Spanish labels`,
messageEs: `Componente BIRHAUS ${componentType} carece de etiquetas en espa\xF1ol`,
suggestedSpanish: this.suggestSpanishLabel(component),
birhausPrinciple: 7,
context: this.getElementContext(component)
});
}
if (this.options.requireEnglishFallback && hasSpanishLabel && !hasEnglishFallback) {
violations.push({
type: "missing-english",
element: component,
severity: "moderate",
message: `BIRHAUS ${componentType} component missing English fallback labels`,
messageEs: `Componente BIRHAUS ${componentType} carece de etiquetas de respaldo en ingl\xE9s`,
suggestedEnglish: this.suggestEnglishLabel(component),
birhausPrinciple: 7,
context: this.getElementContext(component)
});
}
});
return violations;
}
/**
* Validate button labels are in Spanish-first
*/
validateButtonLabels(container) {
const violations = [];
const buttons = container.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]');
buttons.forEach((button) => {
const text = this.getElementText(button);
const hasSpanish = this.isSpanishText(text);
if (!hasSpanish && text.length > 0) {
const suggestedSpanish = this.translateButtonText(text);
violations.push({
type: "missing-spanish",
element: button,
severity: "moderate",
message: `Button text "${text}" should be in Spanish`,
messageEs: `Texto del bot\xF3n "${text}" deber\xEDa estar en espa\xF1ol`,
currentText: text,
suggestedSpanish,
birhausPrinciple: 7,
context: "button"
});
}
});
return violations;
}
/**
* Validate form labels and placeholders
*/
validateFormLabels(container) {
const violations = [];
const formElements = container.querySelectorAll("input, select, textarea");
formElements.forEach((element) => {
const label = this.getAssociatedLabel(element);
if (label) {
const labelText = label.textContent?.trim() || "";
if (!this.isSpanishText(labelText) && labelText.length > 0) {
violations.push({
type: "missing-spanish",
element: label,
severity: "moderate",
message: `Form label "${labelText}" should be in Spanish`,
messageEs: `Etiqueta de formulario "${labelText}" deber\xEDa estar en espa\xF1ol`,
currentText: labelText,
suggestedSpanish: this.translateFormLabel(labelText),
birhausPrinciple: 7,
context: "form"
});
}
}
const placeholder = element.getAttribute("placeholder");
if (placeholder && !this.isSpanishText(placeholder)) {
violations.push({
type: "missing-spanish",
element,
severity: "minor",
message: `Placeholder "${placeholder}" should be in Spanish`,
messageEs: `Marcador de posici\xF3n "${placeholder}" deber\xEDa estar en espa\xF1ol`,
currentText: placeholder,
suggestedSpanish: this.translatePlaceholder(placeholder),
birhausPrinciple: 7,
context: "form"
});
}
});
return violations;
}
/**
* Validate error messages are in Spanish with "Qué pasó + cómo resolver" pattern
*/
validateErrorMessages(container) {
const violations = [];
const errorElements = container.querySelectorAll(
'.error, .alert-error, [role="alert"], [class*="error"], [data-error], [aria-invalid="true"] + *'
);
errorElements.forEach((element) => {
const errorText = element.textContent?.trim() || "";
if (errorText.length > 0) {
const hasSpanish = this.isSpanishText(errorText);
const hasProperPattern = this.hasProperErrorPattern(errorText);
if (!hasSpanish) {
violations.push({
type: "missing-spanish",
element,
severity: "serious",
message: `Error message "${errorText}" should be in Spanish`,
messageEs: `Mensaje de error "${errorText}" deber\xEDa estar en espa\xF1ol`,
currentText: errorText,
suggestedSpanish: this.translateErrorMessage(errorText),
birhausPrinciple: 7,
context: "error"
});
} else if (!hasProperPattern) {
violations.push({
type: "invalid-pattern",
element,
severity: "moderate",
message: `Error message should follow "Qu\xE9 pas\xF3 + c\xF3mo resolver" pattern`,
messageEs: `Mensaje de error deber\xEDa seguir el patr\xF3n "Qu\xE9 pas\xF3 + c\xF3mo resolver"`,
currentText: errorText,
suggestedSpanish: this.improveErrorPattern(errorText),
birhausPrinciple: 7,
context: "error"
});
}
}
});
return violations;
}
/**
* Validate financial terminology is properly translated
*/
validateFinancialTerminology(container) {
const violations = [];
const textElements = container.querySelectorAll(this.options.contentSelector);
textElements.forEach((element) => {
const text = element.textContent?.toLowerCase() || "";
Object.entries(this.financialTerms).forEach(([englishTerm, spanishData]) => {
const regex = new RegExp(`\\b${englishTerm}\\b`, "gi");
if (regex.test(text)) {
violations.push({
type: "financial-term",
element,
severity: this.options.strictFinancialTerms ? "serious" : "moderate",
message: `Financial term "${englishTerm}" should use Spanish "${spanishData.es}"`,
messageEs: `T\xE9rmino financiero "${englishTerm}" deber\xEDa usar espa\xF1ol "${spanishData.es}"`,
currentText: englishTerm,
suggestedSpanish: spanishData.es,
birhausPrinciple: 7,
context: "financial"
});
}
});
});
return violations;
}
/**
* Validate currency formats follow Paraguay conventions
*/
validateCurrencyFormats(container) {
const violations = [];
const textElements = container.querySelectorAll(this.options.contentSelector);
textElements.forEach((element) => {
const text = element.textContent || "";
const currencyPatterns = [
/\$[\d,]+/g,
// Dollar signs
/USD\s*[\d,]+/g,
// USD prefix
/[\d,]+\s*USD/g,
// USD suffix
/₲[\d,]+/g,
// Guaraní symbol
/PYG\s*[\d,]+/g,
// PYG prefix
/[\d,]+\s*PYG/g
// PYG suffix
];
currencyPatterns.forEach((pattern) => {
const matches = text.match(pattern);
if (matches) {
matches.forEach((match) => {
if (!this.isValidParaguayanCurrency(match)) {
violations.push({
type: "invalid-pattern",
element,
severity: "moderate",
message: `Currency format "${match}" should follow Paraguay conventions`,
messageEs: `Formato de moneda "${match}" deber\xEDa seguir convenciones de Paraguay`,
currentText: match,
suggestedSpanish: this.formatAsParaguayanCurrency(match),
birhausPrinciple: 7,
context: "financial"
});
}
});
}
});
});
return violations;
}
// Helper methods
checkSpanishLabels(element) {
const spanishAttributes = [
"data-label-es",
"labelEs",
"aria-label",
"title"
];
return spanishAttributes.some((attr) => {
const value = element.getAttribute(attr);
return value && this.isSpanishText(value);
});
}
checkEnglishFallbacks(element) {
const englishAttributes = [
"data-label-en",
"labelEn"
];
return englishAttributes.some((attr) => {
const value = element.getAttribute(attr);
return value && value.length > 0;
});
}
isSpanishText(text) {
if (!text || text.length === 0) return false;
return this.spanishPatterns.some((pattern) => pattern.test(text)) || // Check for Spanish financial terms
Object.values(this.financialTerms).some(
(term) => text.toLowerCase().includes(term.es) || term.alternatives.some((alt) => text.toLowerCase().includes(alt))
);
}
getElementText(element) {
return element.textContent?.trim() || element.getAttribute("aria-label") || element.getAttribute("title") || element.getAttribute("alt") || "";
}
getElementContext(element) {
const tagName = element.tagName.toLowerCase();
const role = element.getAttribute("role");
const className = element.className;
if (tagName === "button" || role === "button") return "button";
if (tagName === "nav" || role === "navigation") return "navigation";
if (["input", "select", "textarea"].includes(tagName)) return "form";
if (className.includes("error") || element.getAttribute("role") === "alert") return "error";
if (this.isFinancialElement(element)) return "financial";
return "content";
}
isFinancialElement(element) {
const text = this.getElementText(element).toLowerCase();
const className = element.className.toLowerCase();
return Object.keys(this.financialTerms).some(
(term) => text.includes(term) || className.includes(term)
);
}
suggestSpanishLabel(element) {
const componentType = element.getAttribute("data-birhaus-component");
const currentText = this.getElementText(element);
if (currentText) {
return this.translateButtonText(currentText);
}
const suggestions = {
"button": "Bot\xF3n",
"input": "Campo de entrada",
"select": "Lista desplegable",
"card": "Tarjeta",
"dialog": "Di\xE1logo",
"table": "Tabla",
"tabs": "Pesta\xF1as",
"checkbox": "Casilla de verificaci\xF3n"
};
return suggestions[componentType || ""] || "Elemento";
}
suggestEnglishLabel(element) {
const spanishText = this.getElementText(element);
return this.translateToEnglish(spanishText);
}
translateButtonText(text) {
const translations = {
"save": "Guardar",
"cancel": "Cancelar",
"edit": "Editar",
"delete": "Eliminar",
"submit": "Enviar",
"reset": "Restablecer",
"close": "Cerrar",
"open": "Abrir",
"add": "Agregar",
"remove": "Quitar",
"approve": "Aprobar",
"reject": "Rechazar",
"export": "Exportar",
"import": "Importar",
"print": "Imprimir",
"search": "Buscar",
"filter": "Filtrar",
"sort": "Ordenar"
};
const lowerText = text.toLowerCase();
return translations[lowerText] || text;
}
translateFormLabel(text) {
const translations = {
"name": "Nombre",
"email": "Correo electr\xF3nico",
"phone": "Tel\xE9fono",
"address": "Direcci\xF3n",
"amount": "Monto",
"date": "Fecha",
"description": "Descripci\xF3n",
"category": "Categor\xEDa",
"status": "Estado",
"type": "Tipo",
"notes": "Notas",
"comments": "Comentarios"
};
const lowerText = text.toLowerCase();
return translations[lowerText] || text;
}
translatePlaceholder(text) {
const translations = {
"enter name": "Ingrese el nombre",
"enter email": "Ingrese el correo electr\xF3nico",
"enter amount": "Ingrese el monto",
"select date": "Seleccione la fecha",
"search...": "Buscar...",
"type here": "Escriba aqu\xED",
"optional": "Opcional"
};
const lowerText = text.toLowerCase();
return translations[lowerText] || text;
}
translateErrorMessage(text) {
const translations = {
"required field": "Campo obligatorio. Complete este campo para continuar.",
"invalid email": "Correo electr\xF3nico inv\xE1lido. Verifique el formato (ejemplo@dominio.com).",
"invalid amount": "Monto inv\xE1lido. Ingrese un n\xFAmero v\xE1lido mayor a cero.",
"network error": "Error de conexi\xF3n. Verifique su conexi\xF3n a internet e intente nuevamente.",
"server error": "Error del servidor. El problema ser\xE1 resuelto pronto, intente m\xE1s tarde."
};
const lowerText = text.toLowerCase();
return translations[lowerText] || `Error: ${text}. Verifique los datos e intente nuevamente.`;
}
translateToEnglish(spanishText) {
const reverseTranslations = {
"guardar": "Save",
"cancelar": "Cancel",
"editar": "Edit",
"eliminar": "Delete",
"enviar": "Submit",
"cerrar": "Close",
"abrir": "Open",
"agregar": "Add",
"quitar": "Remove"
};
const lowerText = spanishText.toLowerCase();
return reverseTranslations[lowerText] || spanishText;
}
hasProperErrorPattern(text) {
const hasExplanation = /error|problema|fallo|inválido|incorrecto/i.test(text);
const hasSolution = /verifique|intente|complete|corrija|revise/i.test(text);
return hasExplanation && hasSolution;
}
improveErrorPattern(text) {
const solutions = [
"Verifique los datos e intente nuevamente.",
"Complete la informaci\xF3n requerida.",
"Revise el formato e intente otra vez.",
"Intente m\xE1s tarde o contacte soporte."
];
const randomSolution = solutions[Math.floor(Math.random() * solutions.length)];
return `${text} ${randomSolution}`;
}
getAssociatedLabel(element) {
const labelledBy = element.getAttribute("aria-labelledby");
if (labelledBy) {
return document.getElementById(labelledBy);
}
const id = element.getAttribute("id");
if (id) {
return document.querySelector(`label[for="${id}"]`);
}
return element.closest("label");
}
isValidParaguayanCurrency(currencyText) {
const paraguayanPatterns = [
/^₲\s?\d{1,3}(\.\d{3})*$/,
/^PYG\s\d{1,3}(\.\d{3})*$/,
/^\d{1,3}(\.\d{3})*\sPYG$/
];
return paraguayanPatterns.some((pattern) => pattern.test(currencyText));
}
formatAsParaguayanCurrency(currencyText) {
const number = currencyText.replace(/[^\d]/g, "");
const formatted = new Intl.NumberFormat("es-PY").format(parseInt(number));
return `\u20B2 ${formatted}`;
}
};
function expectSpanishFirst(container, strict = true) {
const validator = new SpanishCoverageValidator({
requireSpanishPrimary: true,
strictFinancialTerms: strict
});
const violations = validator.validateAll(container);
const seriousViolations = violations.filter((v) => v.severity === "serious" || v.severity === "critical");
if (seriousViolations.length > 0) {
const errorMessages = seriousViolations.map((v) => v.messageEs).join("\n");
throw new Error(
`Spanish-first violations detected:
${errorMessages}
Total violations: ${violations.length} (${seriousViolations.length} serious)`
);
}
}
function expectBilingualCompleteness(container) {
const validator = new SpanishCoverageValidator({
requireSpanishPrimary: true,
requireEnglishFallback: true
});
const violations = validator.validateAll(container);
if (violations.length > 0) {
const errorMessages = violations.map((v) => v.message).join("\n");
throw new Error(
`Bilingual completeness violations detected:
${errorMessages}`
);
}
}
function expectFinancialSpanish(container) {
const validator = new SpanishCoverageValidator({
validateFinancialVocabulary: true,
strictFinancialTerms: true
});
const violations = validator.validateFinancialTerminology(container || document.body);
if (violations.length > 0) {
const errorMessages = violations.map((v) => `${v.currentText} \u2192 ${v.suggestedSpanish}`).join("\n");
throw new Error(
`Financial terminology violations detected:
${errorMessages}`
);
}
}
var UndoPatternValidator = class {
constructor(options = {}) {
// Confirmation dialog anti-patterns
this.confirmationPatterns = [
/are\s+you\s+sure/i,
/¿está.?\s+seguro/i,
/confirm/i,
/confirmar/i,
/delete.*\?/i,
/eliminar.*\?/i,
/really.*delete/i,
/realmente.*eliminar/i,
/this.*cannot.*be.*undone/i,
/esto.*no.*se.*puede.*deshacer/i
];
// Destructive action patterns
this.destructivePatterns = [
/delete/i,
/eliminar/i,
/remove/i,
/quitar/i,
/destroy/i,
/destruir/i,
/clear/i,
/limpiar/i,
/reset/i,
/restablecer/i,
/purge/i,
/purgar/i
];
this.options = {
detectConfirmationDialogs: true,
validateUndoPresence: true,
validateUndoTimeout: true,
validateUndoMessaging: true,
defaultUndoTimeout: 1e4,
// 10 seconds
maxUndoTimeout: 3e4,
// 30 seconds max
minUndoTimeout: 3e3,
// 3 seconds min
destructiveSelectors: [
'[data-action="delete"]',
'[data-action="remove"]',
".delete-button",
".remove-button",
'button[class*="delete"]',
'button[class*="remove"]'
],
destructiveTexts: [
"delete",
"eliminar",
"remove",
"quitar",
"destroy",
"destruir"
],
requireSpanishUndoMessages: true,
confirmationSelectors: [
'[role="alertdialog"]',
'[role="dialog"]',
".confirmation-dialog",
".confirm-modal",
".alert-dialog"
],
undoSelectors: [
'[data-action="undo"]',
".undo-button",
'button[class*="undo"]',
'[aria-label*="undo"]',
'[aria-label*="deshacer"]'
],
destructiveActionSelectors: [
'button[data-destructive="true"]',
".destructive-action",
'[data-birhaus-component="button"][variant="destructive"]'
],
...options
};
}
/**
* Validate all undo pattern compliance
*/
async validateAll(container = document.body) {
const violations = [];
if (this.options.detectC