const CommandRegistry = require('../src/command-registry'); const _ = require('underscore-plus'); describe("CommandRegistry", () => { let registry, parent, child, grandchild; beforeEach(() => { parent = document.createElement("div"); child = document.createElement("div"); grandchild = document.createElement("div"); parent.classList.add('parent'); child.classList.add('child'); grandchild.classList.add('grandchild'); child.appendChild(grandchild); parent.appendChild(child); document.querySelector('#jasmine-content').appendChild(parent); registry = new CommandRegistry; registry.attach(parent); }); afterEach(() => registry.destroy()); describe("when a command event is dispatched on an element", () => { it("invokes callbacks with selectors matching the target", () => { let called = false; registry.add('.grandchild', 'command', function (event) { expect(this).toBe(grandchild); expect(event.type).toBe('command'); expect(event.eventPhase).toBe(Event.BUBBLING_PHASE); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(grandchild); called = true; }); grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(called).toBe(true); }); it("invokes callbacks with selectors matching ancestors of the target", () => { const calls = []; registry.add('.child', 'command', function (event) { expect(this).toBe(child); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(child); calls.push('child'); }); registry.add('.parent', 'command', function (event) { expect(this).toBe(parent); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(parent); calls.push('parent'); }); grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(calls).toEqual(['child', 'parent']); }); it("invokes inline listeners prior to listeners applied via selectors", () => { const calls = []; registry.add('.grandchild', 'command', () => calls.push('grandchild')); registry.add(child, 'command', () => calls.push('child-inline')); registry.add('.child', 'command', () => calls.push('child')); registry.add('.parent', 'command', () => calls.push('parent')); grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(calls).toEqual(['grandchild', 'child-inline', 'child', 'parent']); }); it("orders multiple matching listeners for an element by selector specificity", () => { child.classList.add('foo', 'bar'); const calls = []; registry.add('.foo.bar', 'command', () => calls.push('.foo.bar')); registry.add('.foo', 'command', () => calls.push('.foo')); registry.add('.bar', 'command', () => calls.push('.bar')); // specificity ties favor commands added later, like CSS grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(calls).toEqual(['.foo.bar', '.bar', '.foo']); }); it("orders inline listeners by reverse registration order", () => { const calls = []; registry.add(child, 'command', () => calls.push('child1')); registry.add(child, 'command', () => calls.push('child2')); child.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(calls).toEqual(['child2', 'child1']); }); it("stops bubbling through ancestors when .stopPropagation() is called on the event", () => { const calls = []; registry.add('.parent', 'command', () => calls.push('parent')); registry.add('.child', 'command', () => calls.push('child-2')); registry.add('.child', 'command', (event) => { calls.push('child-1'); event.stopPropagation(); }); const dispatchedEvent = new CustomEvent('command', {bubbles: true}); spyOn(dispatchedEvent, 'stopPropagation'); grandchild.dispatchEvent(dispatchedEvent); expect(calls).toEqual(['child-1', 'child-2']); expect(dispatchedEvent.stopPropagation).toHaveBeenCalled(); }); it("stops invoking callbacks when .stopImmediatePropagation() is called on the event", () => { const calls = []; registry.add('.parent', 'command', () => calls.push('parent')); registry.add('.child', 'command', () => calls.push('child-2')); registry.add('.child', 'command', (event) => { calls.push('child-1'); event.stopImmediatePropagation(); }); const dispatchedEvent = new CustomEvent('command', {bubbles: true}); spyOn(dispatchedEvent, 'stopImmediatePropagation'); grandchild.dispatchEvent(dispatchedEvent); expect(calls).toEqual(['child-1']); expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled(); }); it("forwards .preventDefault() calls from the synthetic event to the original", () => { registry.add('.child', 'command', event => event.preventDefault()); const dispatchedEvent = new CustomEvent('command', {bubbles: true}); spyOn(dispatchedEvent, 'preventDefault'); grandchild.dispatchEvent(dispatchedEvent); expect(dispatchedEvent.preventDefault).toHaveBeenCalled(); }); it("forwards .abortKeyBinding() calls from the synthetic event to the original", () => { registry.add('.child', 'command', event => event.abortKeyBinding()); const dispatchedEvent = new CustomEvent('command', {bubbles: true}); dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding'); grandchild.dispatchEvent(dispatchedEvent); expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled(); }); it("copies non-standard properties from the original event to the synthetic event", () => { let syntheticEvent = null; registry.add('.child', 'command', event => syntheticEvent = event); const dispatchedEvent = new CustomEvent('command', {bubbles: true}); dispatchedEvent.nonStandardProperty = 'testing'; grandchild.dispatchEvent(dispatchedEvent); expect(syntheticEvent.nonStandardProperty).toBe('testing'); }); it("allows listeners to be removed via a disposable returned by ::add", () => { let calls = []; const disposable1 = registry.add('.parent', 'command', () => calls.push('parent')); const disposable2 = registry.add('.child', 'command', () => calls.push('child')); disposable1.dispose(); grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(calls).toEqual(['child']); calls = []; disposable2.dispose(); grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(calls).toEqual([]); }); it("allows multiple commands to be registered under one selector when called with an object", () => { let calls = []; const disposable = registry.add('.child', { 'command-1'() { calls.push('command-1'); }, 'command-2'() { calls.push('command-2'); } }); grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true})); grandchild.dispatchEvent(new CustomEvent('command-2', {bubbles: true})); expect(calls).toEqual(['command-1', 'command-2']); calls = []; disposable.dispose(); grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true})); grandchild.dispatchEvent(new CustomEvent('command-2', {bubbles: true})); expect(calls).toEqual([]); }); it("invokes callbacks registered with ::onWillDispatch and ::onDidDispatch", () => { const sequence = []; registry.onDidDispatch(event => sequence.push(['onDidDispatch', event])); registry.add('.grandchild', 'command', event => sequence.push(['listener', event])); registry.onWillDispatch(event => sequence.push(['onWillDispatch', event])); grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true})); expect(sequence[0][0]).toBe('onWillDispatch'); expect(sequence[1][0]).toBe('listener'); expect(sequence[2][0]).toBe('onDidDispatch'); expect(sequence[0][1] === sequence[1][1] && sequence[1][1] === sequence[2][1]).toBe(true); expect(sequence[0][1].constructor).toBe(CustomEvent); expect(sequence[0][1].target).toBe(grandchild); }); }); describe("::add(selector, commandName, callback)", () => { it("throws an error when called with an invalid selector", () => { const badSelector = '<>'; let addError = null; try { registry.add(badSelector, 'foo:bar', () => {}); } catch (error) { addError = error; } expect(addError.message).toContain(badSelector); }); it("throws an error when called with a null callback and selector target", () => { const badCallback = null; expect(() => { registry.add('.selector', 'foo:bar', badCallback); }).toThrow(new Error('Cannot register a command with a null listener.')); }); it("throws an error when called with a null callback and object target", () => { const badCallback = null; expect(() => { registry.add(document.body, 'foo:bar', badCallback); }).toThrow(new Error('Cannot register a command with a null listener.')); }); it("throws an error when called with an object listener without a didDispatch method", () => { const badListener = { title: 'a listener without a didDispatch callback', description: 'this should throw an error' }; expect(() => { registry.add(document.body, 'foo:bar', badListener); }).toThrow(new Error('Listener must be a callback function or an object with a didDispatch method.')); }); }); describe("::findCommands({target})", () => { it("returns command descriptors that can be invoked on the target or its ancestors", () => { registry.add('.parent', 'namespace:command-1', () => {}); registry.add('.child', 'namespace:command-2', () => {}); registry.add('.grandchild', 'namespace:command-3', () => {}); registry.add('.grandchild.no-match', 'namespace:command-4', () => {}); registry.add(grandchild, 'namespace:inline-command-1', () => {}); registry.add(child, 'namespace:inline-command-2', () => {}); const commands = registry.findCommands({target: grandchild}); const nonJqueryCommands = _.reject(commands, cmd => cmd.jQuery); expect(nonJqueryCommands).toEqual([ {name: 'namespace:inline-command-1', displayName: 'Namespace: Inline Command 1'}, {name: 'namespace:command-3', displayName: 'Namespace: Command 3'}, {name: 'namespace:inline-command-2', displayName: 'Namespace: Inline Command 2'}, {name: 'namespace:command-2', displayName: 'Namespace: Command 2'}, {name: 'namespace:command-1', displayName: 'Namespace: Command 1'} ]); }); it("returns command descriptors with arbitrary metadata if set in a listener object", () => { registry.add('.grandchild', 'namespace:command-1', () => {}); registry.add('.grandchild', 'namespace:command-2', { displayName: 'Custom Command 2', metadata: { some: 'other', object: 'data' }, didDispatch() {} }); registry.add('.grandchild', 'namespace:command-3', { name: 'some:other:incorrect:commandname', displayName: 'Custom Command 3', metadata: { some: 'other', object: 'data' }, didDispatch() {} }); const commands = registry.findCommands({target: grandchild}); expect(commands).toEqual([ { displayName: 'Namespace: Command 1', name: 'namespace:command-1' }, { displayName: 'Custom Command 2', metadata: { some : 'other', object : 'data' }, name: 'namespace:command-2' }, { displayName: 'Custom Command 3', metadata: { some : 'other', object : 'data' }, name: 'namespace:command-3' } ]); }); it("returns command descriptors with arbitrary metadata if set on a listener function", () => { function listener () {} listener.displayName = 'Custom Command 2' listener.metadata = { some: 'other', object: 'data' }; registry.add('.grandchild', 'namespace:command-2', listener); const commands = registry.findCommands({target: grandchild}); expect(commands).toEqual([ { displayName : 'Custom Command 2', metadata: { some: 'other', object: 'data' }, name: 'namespace:command-2' } ]); }); }); describe("::dispatch(target, commandName)", () => { it("simulates invocation of the given command ", () => { let called = false; registry.add('.grandchild', 'command', function (event) { expect(this).toBe(grandchild); expect(event.type).toBe('command'); expect(event.eventPhase).toBe(Event.BUBBLING_PHASE); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(grandchild); called = true; }); registry.dispatch(grandchild, 'command'); expect(called).toBe(true); }); it("returns a boolean indicating whether any listeners matched the command", () => { registry.add('.grandchild', 'command', () => {}); expect(registry.dispatch(grandchild, 'command')).toBe(true); expect(registry.dispatch(grandchild, 'bogus')).toBe(false); expect(registry.dispatch(parent, 'command')).toBe(false); }); }); describe("::getSnapshot and ::restoreSnapshot", () => it("removes all command handlers except for those in the snapshot", () => { registry.add('.parent', 'namespace:command-1', () => {}); registry.add('.child', 'namespace:command-2', () => {}); const snapshot = registry.getSnapshot(); registry.add('.grandchild', 'namespace:command-3', () => {}); expect(registry.findCommands({target: grandchild}).slice(0, 3)).toEqual([ {name: 'namespace:command-3', displayName: 'Namespace: Command 3'}, {name: 'namespace:command-2', displayName: 'Namespace: Command 2'}, {name: 'namespace:command-1', displayName: 'Namespace: Command 1'} ]); registry.restoreSnapshot(snapshot); expect(registry.findCommands({target: grandchild}).slice(0, 2)).toEqual([ {name: 'namespace:command-2', displayName: 'Namespace: Command 2'}, {name: 'namespace:command-1', displayName: 'Namespace: Command 1'} ]); registry.add('.grandchild', 'namespace:command-3', () => {}); registry.restoreSnapshot(snapshot); expect(registry.findCommands({target: grandchild}).slice(0, 2)).toEqual([ {name: 'namespace:command-2', displayName: 'Namespace: Command 2'}, {name: 'namespace:command-1', displayName: 'Namespace: Command 1'} ]); }) ); describe("::attach(rootNode)", () => it("adds event listeners for any previously-added commands", () => { const registry2 = new CommandRegistry; const commandSpy = jasmine.createSpy('command-callback'); registry2.add('.grandchild', 'command-1', commandSpy); grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true})); expect(commandSpy).not.toHaveBeenCalled(); registry2.attach(parent); grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true})); expect(commandSpy).toHaveBeenCalled(); }) ); });