#!/usr/bin/env node /** * Lightweight Test Runner for Entity Selector Validation * No heavy dependencies - runs directly with Node.js */ const { JSDOM } = require('jsdom'); const fs = require('fs'); const path = require('path'); // Setup DOM environment with script execution enabled const dom = new JSDOM('', { url: 'http://localhost', runScripts: 'dangerously', resources: 'usable' }); const window = dom.window; const document = window.document; // Initialize mixin namespace before loading scripts window._EntitySelectorMixins = {}; // Load jQuery using window.eval to properly share context const jqueryCode = fs.readFileSync( path.join(__dirname, '../node_modules/jquery/dist/jquery.min.js'), 'utf8' ); window.eval(jqueryCode); // Make $ available in Node context const $ = window.jQuery; // Load and execute the validation mixin using window.eval const validationCode = fs.readFileSync( path.join(__dirname, '../sources/js/admin/entity-selector/_validation.js'), 'utf8' ); window.eval(validationCode); // Get the validation mixin const validationMixin = window._EntitySelectorMixins.validation; // Test utilities let passed = 0; let failed = 0; let currentGroup = ''; function describe(name, fn) { currentGroup = name; console.log(`\n\x1b[1m${name}\x1b[0m`); fn(); } function test(name, fn) { try { fn(); passed++; console.log(` \x1b[32m✓\x1b[0m ${name}`); } catch (e) { failed++; console.log(` \x1b[31m✗\x1b[0m ${name}`); console.log(` \x1b[31m${e.message}\x1b[0m`); } } function expect(actual) { return { toBe(expected) { if (actual !== expected) { throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } }, toEqual(expected) { if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } }, toContain(expected) { if (typeof actual === 'string') { if (!actual.includes(expected)) { throw new Error(`Expected "${actual}" to contain "${expected}"`); } } else if (Array.isArray(actual)) { if (!actual.includes(expected)) { throw new Error(`Expected array to contain ${expected}`); } } }, toBeDefined() { if (actual === undefined) { throw new Error('Expected value to be defined'); } } }; } // Test fixtures function createValidator() { const $wrapper = $(`
`); $(document.body).empty().append($wrapper); return Object.assign({}, validationMixin, { $wrapper: $wrapper, $dropdown: $('
'), config: { trans: {} }, activeGroup: { blockType: 'categories', groupIndex: 0, section: 'include', excludeIndex: 0, searchEntity: 'categories' }, treeFlatData: null, escapeHtml: (str) => str, escapeAttr: (str) => str }); } function createMockChips($picker, ids) { const $chips = $picker.find('.entity-chips'); $chips.empty(); ids.forEach(id => { $chips.append(``); }); } // ========================================================================= // TESTS // ========================================================================= console.log('\n\x1b[36m╔════════════════════════════════════════════════════════════╗\x1b[0m'); console.log('\x1b[36m║ Entity Selector Validation Test Suite ║\x1b[0m'); console.log('\x1b[36m╚════════════════════════════════════════════════════════════╝\x1b[0m'); // GROUP 1: Include/Exclude Conflict Detection describe('Include/Exclude Conflict Detection', () => { test('TC-001: Block adding to EXCLUDE when entity is in INCLUDE', () => { const validator = createValidator(); const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [5]); validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(5, 'Electronics', 'exclude', {}); expect(result.valid).toBe(false); expect(result.type).toBe('include_exclude_conflict'); expect(result.error).toContain('already in the include list'); }); test('TC-002: Block adding to INCLUDE when entity is in EXCLUDE', () => { const validator = createValidator(); const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [5]); validator.activeGroup.section = 'include'; const result = validator.validateSelection(5, 'Electronics', 'include', {}); expect(result.valid).toBe(false); expect(result.type).toBe('include_exclude_conflict'); expect(result.error).toContain('already in the exclude list'); }); test('TC-003: Allow adding to INCLUDE when entity is NOT in EXCLUDE', () => { const validator = createValidator(); const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [10]); validator.activeGroup.section = 'include'; const result = validator.validateSelection(5, 'Electronics', 'include', {}); expect(result.valid).toBe(true); }); test('TC-004: Allow adding to EXCLUDE when entity is NOT in INCLUDE', () => { const validator = createValidator(); const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [10]); validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(5, 'Electronics', 'exclude', {}); expect(result.valid).toBe(true); }); test('TC-005: Check across multiple exclude rows', () => { const validator = createValidator(); const $container = validator.$wrapper.find('.exclude-rows-container'); $container.append(`
`); validator.activeGroup.section = 'include'; const result = validator.validateSelection(5, 'Electronics', 'include', {}); expect(result.valid).toBe(false); expect(result.type).toBe('include_exclude_conflict'); }); }); // GROUP 2: Redundant Selection Detection describe('Redundant Selection Detection', () => { test('TC-010: Block duplicate selection in INCLUDE', () => { const validator = createValidator(); const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [5]); validator.activeGroup.section = 'include'; const result = validator.validateSelection(5, 'Electronics', 'include', {}); expect(result.valid).toBe(false); expect(result.type).toBe('redundant'); expect(result.error).toContain('already selected'); }); test('TC-011: Block duplicate selection in EXCLUDE', () => { const validator = createValidator(); const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [5]); validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(5, 'Electronics', 'exclude', {}); expect(result.valid).toBe(false); expect(result.type).toBe('redundant'); expect(result.error).toContain('already in an exclude list'); }); test('TC-012: Allow selection when not duplicate', () => { const validator = createValidator(); const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [10]); validator.activeGroup.section = 'include'; const result = validator.validateSelection(5, 'Electronics', 'include', {}); expect(result.valid).toBe(true); }); }); // GROUP 3: Parent-Child Conflict Detection describe('Parent-Child Conflict Detection', () => { const treeData = [ { id: 1, name: 'Electronics', parent_id: 0 }, { id: 2, name: 'Computers', parent_id: 1 }, { id: 3, name: 'Phones', parent_id: 1 }, { id: 4, name: 'Laptops', parent_id: 2 }, { id: 5, name: 'Desktops', parent_id: 2 }, { id: 6, name: 'Smartphones', parent_id: 3 }, { id: 7, name: 'Feature Phones', parent_id: 3 } ]; test('TC-020: Block including child when PARENT is EXCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [2]); // Computers excluded validator.activeGroup.section = 'include'; const result = validator.validateSelection(4, 'Laptops', 'include', { parentId: 2 }); expect(result.valid).toBe(false); expect(result.type).toBe('parent_excluded'); expect(result.error).toContain('Computers'); }); test('TC-021: Block including grandchild when GRANDPARENT is EXCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [1]); // Electronics excluded validator.activeGroup.section = 'include'; const result = validator.validateSelection(4, 'Laptops', 'include', { parentId: 2 }); expect(result.valid).toBe(false); expect(result.type).toBe('parent_excluded'); expect(result.error).toContain('Electronics'); }); test('TC-022: Allow including child when SIBLING is EXCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [5]); // Desktops (sibling) excluded validator.activeGroup.section = 'include'; const result = validator.validateSelection(4, 'Laptops', 'include', { parentId: 2 }); expect(result.valid).toBe(true); }); test('TC-030: Block including parent when CHILD is EXCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [4]); // Laptops excluded validator.activeGroup.section = 'include'; const result = validator.validateSelection(2, 'Computers', 'include', { parentId: 1 }); expect(result.valid).toBe(false); expect(result.type).toBe('child_excluded'); expect(result.error).toContain('Laptops'); }); test('TC-031: Block including grandparent when GRANDCHILD is EXCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [4]); // Laptops excluded validator.activeGroup.section = 'include'; const result = validator.validateSelection(1, 'Electronics', 'include', { parentId: 0 }); expect(result.valid).toBe(false); expect(result.type).toBe('child_excluded'); }); test('TC-040: Block excluding child when PARENT is INCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [2]); // Computers included validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(4, 'Laptops', 'exclude', { parentId: 2 }); expect(result.valid).toBe(false); expect(result.type).toBe('parent_included'); }); test('TC-050: Block excluding parent when CHILDREN are INCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [4, 5]); // Laptops & Desktops included validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(2, 'Computers', 'exclude', { parentId: 1 }); expect(result.valid).toBe(false); expect(result.type).toBe('children_included'); }); test('TC-051: Block excluding grandparent when GRANDCHILDREN are INCLUDED', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [4]); // Laptops included validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(1, 'Electronics', 'exclude', { parentId: 0 }); expect(result.valid).toBe(false); expect(result.type).toBe('children_included'); }); test('TC-052: Allow excluding when no children are included', () => { const validator = createValidator(); validator.treeFlatData = treeData; const $includePicker = validator.$wrapper.find('.include-picker'); createMockChips($includePicker, [3]); // Phones (different branch) included validator.activeGroup.section = 'exclude'; const result = validator.validateSelection(2, 'Computers', 'exclude', { parentId: 1 }); expect(result.valid).toBe(true); }); }); // GROUP 4: Non-Tree Entities describe('Non-Tree Entity Validation', () => { test('TC-060: Products - no tree validation, only include/exclude check', () => { const validator = createValidator(); validator.activeGroup.searchEntity = 'products'; validator.treeFlatData = null; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [100]); validator.activeGroup.section = 'include'; const result = validator.validateSelection(100, 'Product A', 'include', {}); expect(result.valid).toBe(false); expect(result.type).toBe('include_exclude_conflict'); }); test('TC-061: Manufacturers - allow selection without tree validation', () => { const validator = createValidator(); validator.activeGroup.searchEntity = 'manufacturers'; validator.treeFlatData = null; validator.activeGroup.section = 'include'; const result = validator.validateSelection(50, 'Nike', 'include', {}); expect(result.valid).toBe(true); }); }); // GROUP 5: Edge Cases describe('Edge Cases', () => { test('TC-070: Empty selections - allow any selection', () => { const validator = createValidator(); validator.activeGroup.section = 'include'; const result = validator.validateSelection(1, 'Category A', 'include', {}); expect(result.valid).toBe(true); }); test('TC-071: No activeGroup - return valid (fail-safe)', () => { const validator = createValidator(); validator.activeGroup = null; const result = validator.validateSelection(1, 'Category A', 'include', {}); expect(result.valid).toBe(true); }); test('TC-072: String ID coercion to integer', () => { const validator = createValidator(); const $includePicker = validator.$wrapper.find('.include-picker'); $includePicker.find('.entity-chips').append(''); validator.activeGroup.section = 'include'; const result = validator.validateSelection(5, 'Category', 'include', {}); expect(result.valid).toBe(false); expect(result.type).toBe('redundant'); }); test('TC-074: Root category (parent_id = 0)', () => { const validator = createValidator(); validator.treeFlatData = [ { id: 1, name: 'Root', parent_id: 0 }, { id: 2, name: 'Child', parent_id: 1 } ]; validator.activeGroup.section = 'include'; const result = validator.validateSelection(1, 'Root', 'include', { parentId: 0 }); expect(result.valid).toBe(true); }); test('TC-075: Deep nesting (6 levels)', () => { const validator = createValidator(); validator.treeFlatData = [ { id: 1, name: 'Level 1', parent_id: 0 }, { id: 2, name: 'Level 2', parent_id: 1 }, { id: 3, name: 'Level 3', parent_id: 2 }, { id: 4, name: 'Level 4', parent_id: 3 }, { id: 5, name: 'Level 5', parent_id: 4 }, { id: 6, name: 'Level 6', parent_id: 5 } ]; const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [1]); // Root excluded validator.activeGroup.section = 'include'; const result = validator.validateSelection(6, 'Level 6', 'include', { parentId: 5 }); expect(result.valid).toBe(false); expect(result.type).toBe('parent_excluded'); }); }); // GROUP 6: Helper Functions describe('Helper Functions', () => { test('TC-080: getChipIds returns correct IDs', () => { const validator = createValidator(); const $picker = validator.$wrapper.find('.include-picker'); createMockChips($picker, [1, 5, 10, 25]); const ids = validator.getChipIds($picker); expect(ids).toEqual([1, 5, 10, 25]); }); test('TC-081: getAncestorIds returns correct ancestors', () => { const validator = createValidator(); validator.treeFlatData = [ { id: 1, name: 'Root', parent_id: 0 }, { id: 2, name: 'Child', parent_id: 1 }, { id: 3, name: 'Grandchild', parent_id: 2 } ]; const lookup = {}; validator.treeFlatData.forEach(item => { lookup[item.id] = item; }); const ancestors = validator.getAncestorIds(3, lookup); expect(ancestors).toContain(2); expect(ancestors).toContain(1); expect(ancestors.length).toBe(2); }); test('TC-082: getDescendantIds returns correct descendants', () => { const validator = createValidator(); validator.treeFlatData = [ { id: 1, name: 'Root', parent_id: 0 }, { id: 2, name: 'Child A', parent_id: 1 }, { id: 3, name: 'Child B', parent_id: 1 }, { id: 4, name: 'Grandchild', parent_id: 2 } ]; const lookup = {}; validator.treeFlatData.forEach(item => { lookup[item.id] = item; }); const descendants = validator.getDescendantIds(1, lookup); expect(descendants).toContain(2); expect(descendants).toContain(3); expect(descendants).toContain(4); expect(descendants.length).toBe(3); }); }); // GROUP 7: Bulk Validation describe('Bulk Validation (validatePendingSelections)', () => { test('TC-090: Returns errors for conflicts', () => { const validator = createValidator(); const $excludePicker = validator.$wrapper.find('.exclude-picker'); createMockChips($excludePicker, [5]); const pendingSelections = [ { id: 1, name: 'Category A', data: {} }, { id: 5, name: 'Category E', data: {} }, // Conflict { id: 10, name: 'Category J', data: {} } ]; const errors = validator.validatePendingSelections(pendingSelections, 'include'); expect(errors.length).toBe(1); expect(errors[0].id).toBe(5); expect(errors[0].type).toBe('include_exclude_conflict'); }); test('TC-091: Returns empty for valid selections', () => { const validator = createValidator(); const pendingSelections = [ { id: 1, name: 'Category A', data: {} }, { id: 2, name: 'Category B', data: {} } ]; const errors = validator.validatePendingSelections(pendingSelections, 'include'); expect(errors.length).toBe(0); }); test('TC-092: Handles empty array', () => { const validator = createValidator(); const errors = validator.validatePendingSelections([], 'include'); expect(errors.length).toBe(0); }); test('TC-093: Handles null', () => { const validator = createValidator(); const errors = validator.validatePendingSelections(null, 'include'); expect(errors.length).toBe(0); }); }); // ========================================================================= // SUMMARY // ========================================================================= console.log('\n\x1b[36m══════════════════════════════════════════════════════════════\x1b[0m'); console.log(`\x1b[1mTest Results:\x1b[0m`); console.log(` \x1b[32m✓ Passed: ${passed}\x1b[0m`); if (failed > 0) { console.log(` \x1b[31m✗ Failed: ${failed}\x1b[0m`); } else { console.log('\n \x1b[32m🎉 All tests passed!\x1b[0m'); } console.log(`\n Total: ${passed + failed}`); console.log('\x1b[36m══════════════════════════════════════════════════════════════\x1b[0m\n'); process.exit(failed > 0 ? 1 : 0);