diff --git a/__tests__/commands/__snapshots__/help.test.ts.snap b/__tests__/commands/__snapshots__/help.test.ts.snap new file mode 100644 index 0000000..989a2ed --- /dev/null +++ b/__tests__/commands/__snapshots__/help.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Help Command Execute DMs commands with prefix and descriptions it still looks the same 1`] = ` +"I am here to help! Well...mostly just make you chuckle at this point, let's be honest. + +Here is a list of the commands that we've got right now: +\`\`\` +!one → I am number one. +!two → Two is not just a number. +!blueFish → Not a red fish. +\`\`\`" +`; diff --git a/__tests__/commands/help.test.ts b/__tests__/commands/help.test.ts new file mode 100644 index 0000000..3ee5bed --- /dev/null +++ b/__tests__/commands/help.test.ts @@ -0,0 +1,110 @@ +import Help from '../../src/commands/help'; +import Commands from '../../src/library/commands'; +import { message as mockMessage, MockedMessage } from '../mocks/discord'; + +// TODO: These should be in a factory/mock +const oneCommand = { + name: 'one', + description: 'I am number one.', + execute: jest.fn() +}; + +const twoCommand = { + name: 'two', + description: 'Two is not just a number.', + execute: jest.fn() +}; + +const blueFishCommand = { + name: 'blueFish', + description: 'Not a red fish.', + execute: jest.fn() +}; + +const commands = new Commands({ + one: oneCommand, + two: twoCommand, + blueFish: blueFishCommand +}); + +let sendMock: MockedMessage; +let authorSend: MockedMessage; +beforeEach(() => { + sendMock = jest.fn(); + mockMessage.reply = sendMock; + authorSend = jest.fn(); + // @ts-ignore + mockMessage.author = { + send: authorSend + }; +}); + +describe('Help Command', () => { + describe('Execute', () => { + beforeEach(() => { + Help.execute([], mockMessage, { commands }); + }); + + test('Lets you know to check your DMs', () => { + expect(sendMock).lastCalledWith('sliding into your DMs...'); + }); + + describe('DMs commands with prefix and descriptions', () => { + let message: string; + beforeEach(() => { + message = authorSend.mock.calls[0][0]; + }); + + test('Snarky', () => { + const snark = "I am here to help! Well...mostly just make you chuckle " + + "at this point, let's be honest."; + expect(message).toContain(snark); + }); + + test('Command pretext header', () => { + const pretext = "Here is a list of the commands that we've got right now:"; + expect(message).toContain(pretext); + }); + + test('Code block start', () => { + expect(message).toContain('```\n'); + }); + + test('Code block end', () => { + const lines = message.split('\n'); + const lastLine = lines[lines.length - 1]; + expect(lastLine).toEqual('```'); + }); + + test('it still looks the same', () => { + expect(message).toMatchSnapshot(); + }); + + describe('Commands', () => { + test('one command', () => { + expect(message).toContain('!one'); + }); + + test('one description', () => { + expect(message).toContain(oneCommand.description); + }); + + test('two command', () => { + expect(message).toContain('!two'); + }); + + test('two description', () => { + expect(message).toContain(twoCommand.description); + }); + + test('blueFish command', () => { + expect(message).toContain('!blueFish'); + }); + + test('BlueFish description', () => { + expect(message).toContain(blueFishCommand.description); + }); + }); + }); + }); +}); diff --git a/__tests__/commands/search.test.ts b/__tests__/commands/search.test.ts index e66db5b..af09665 100644 --- a/__tests__/commands/search.test.ts +++ b/__tests__/commands/search.test.ts @@ -43,10 +43,24 @@ describe('Search Command', () => { await Search.execute(['dingusy'], mockMessage); expect(sendMock).lastCalledWith(results.items[0].link); }); - test('Malformed Response', async () => { - const mockedData = Promise.resolve({ data: {} }); - axiosMock.get.mockResolvedValueOnce(mockedData); - await Search.execute(['NOPE'], mockMessage); - expect(sendMock).lastCalledWith("I'm Sorry Dave, I'm afraid I can't do that..."); + describe('Malformed Response', () => { + let consoleErrorMock: jest.SpyInstance; + beforeEach(async () => { + const mockedData = Promise.resolve({ data: {} }); + axiosMock.get.mockResolvedValueOnce(mockedData); + consoleErrorMock = jest.spyOn(console, 'error') + .mockImplementation(() => undefined); // Prevent it from spewing into the test results + await Search.execute(['NOPE'], mockMessage); + }); + afterEach(() => { + consoleErrorMock.mockRestore(); + }); + test('Responds with error message', async () => { + expect(sendMock).lastCalledWith("I'm Sorry Dave, I'm afraid I can't do that..."); + }); + test('Console logs an error', () => { + const errorMessage = "Malformed Google Search Response: {}"; + expect(consoleErrorMock).lastCalledWith(errorMessage); + }); }); }); diff --git a/__tests__/library/commandLoader.test.ts b/__tests__/library/commandLoader.test.ts index 8573a77..55ad0cb 100644 --- a/__tests__/library/commandLoader.test.ts +++ b/__tests__/library/commandLoader.test.ts @@ -1,10 +1,10 @@ import glob from 'glob'; import CommandLoader, { ICommandClasses } from '../../src/library/commandLoader'; -import { COMMANDS_PATH_GLOB } from './../../src/library/commands'; describe('CommandLoader', () => { let commandClasses: ICommandClasses; - const files = glob.sync(COMMANDS_PATH_GLOB); + const commandsPathGlob = './src/commands/*.ts'; + const files = glob.sync(commandsPathGlob); beforeEach(() => { commandClasses = CommandLoader.getCommandClasses(files); @@ -16,6 +16,7 @@ describe('CommandLoader', () => { }); }); + // More of an integration test against real commands describe('Class names match their file names', () => { test('they match their key name', () => { for (let commandName of Object.keys(commandClasses)) { diff --git a/__tests__/library/commands.test.ts b/__tests__/library/commands.test.ts index 8f0f0ec..ea80a62 100644 --- a/__tests__/library/commands.test.ts +++ b/__tests__/library/commands.test.ts @@ -1,16 +1,43 @@ +import { ICommandClasses } from '../../src/library/commandLoader'; import Commands from '../../src/library/commands'; +import ICommand from '../../src/library/iCommand'; -// TODO: I feel like this class is too basic to test, -// and ultimately a wrapper for other classes that have been tested -// Might just remove it some day describe('Commands', () => { - const commands = new Commands(); - test('Has a command', () => { - expect(Object.keys(commands.all).length).toBeGreaterThan(0); - expect(commands.names.length).toBeGreaterThan(0); + let mockHelloCommand: ICommand; + let mockYetAnotherCommand: ICommand; + let mockCommands: ICommandClasses; + let commands: Commands; + + beforeEach(() => { + // TODO: these should probably go into a factory/mock + mockHelloCommand = { + name: 'Hello', + description: 'Hello World', + execute: jest.fn() + }; + mockYetAnotherCommand = { + name: 'YAC', + description: 'Yet Another Command!', + execute: jest.fn() + }; + mockCommands = { + hello: mockHelloCommand, + yac: mockYetAnotherCommand + }; + commands = new Commands(mockCommands); + }); + + test('.names returns command names', () => { + const commandNames = ['hello', 'yac']; + expect(commands.names).toEqual(commandNames); }); + test('Can fetch a command', () => { - const first = commands.names[0]; - expect(commands.get(first)).not.toBeUndefined(); + const helloCommand = commands.get('hello'); + expect(helloCommand).toBe(mockHelloCommand); + }); + + test('Finds the longest name', () => { + expect(commands.longestNameLength()).toEqual(5); }); }); diff --git a/package-lock.json b/package-lock.json index 631e376..bd88a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hackbot", - "version": "2.0.3", + "version": "2.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index de36e7e..cd6a475 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hackbot", - "version": "2.0.4", + "version": "2.1.0", "description": "Discord bot for the Cascades Tech Club Discord server.", "repository": { "type": "git", diff --git a/src/commands/help.ts b/src/commands/help.ts index 852a346..e613db1 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -12,26 +12,33 @@ export default Help = class { return 'Displays this message'; } - public static execute(args: string[], msg: Message) { - const commands = new Commands(); - const longest = commands.longestName(); + public static execute( + args: string[], + msg: Message, + { commands }: { commands: Commands } + ) { + const helpMsg = "I am here to help! Well...mostly just make you chuckle at this point, let's be honest.\n\n" + + "Here is a list of the commands that we've got right now:\n" + + '```\n' + + this.commandsAndDescriptions(commands) + + '```'; - let helpMsg = "I am here to help! Well...mostly just make you chuckle at this point, let's be honest.\n\n"; - helpMsg += "Here is a list of the commands that we've got right now:\n"; - helpMsg += '```\n'; + msg.reply('sliding into your DMs...'); + msg.author.send(helpMsg); + } + + private static commandsAndDescriptions(commands: Commands) { + const prefixLength = config.messagePrefix.length; + const longest = commands.longestNameLength() + prefixLength; - commands.names.map((commandName) => { + return commands.names.reduce((message, commandName) => { const command = commands.get(commandName); - const amountOfSpaces = longest - commandName.length; - helpMsg += `${config.messagePrefix}${commandName}`; + const commandPrefixLength = commandName.length + prefixLength; + const amountOfSpaces = longest - commandPrefixLength; + return message += `${config.messagePrefix}${commandName}` + // TODO: needs args implemented here after they're part of the magic - helpMsg += ' '.repeat(amountOfSpaces); - helpMsg += `→ ${command.description}\n`; - }); - - helpMsg += '```'; - - msg.reply('sliding into your DMs...'); - msg.author.send(helpMsg); + ' '.repeat(amountOfSpaces) + + ` → ${command.description}\n`; + }, ''); } }; diff --git a/src/commands/purge.ts b/src/commands/purge.ts index eed65c7..1e0ef14 100644 --- a/src/commands/purge.ts +++ b/src/commands/purge.ts @@ -10,7 +10,11 @@ export default Purge = class { return 'Purges the channel it is called within. Restricted to Board Members and Administrators.'; } - public static execute(args: string[], msg: Message, bot: Client) { + public static execute( + args: string[], + msg: Message, + { client: bot }: { client: Client } + ) { const { guild } = msg; /* global bot */ diff --git a/src/library/commandLoader.ts b/src/library/commandLoader.ts index 1035e4a..013bef5 100644 --- a/src/library/commandLoader.ts +++ b/src/library/commandLoader.ts @@ -25,6 +25,7 @@ export default class CommandLoader { } private static removeTemplateFile(files: string[]) { - return files.filter(file => file !== './src/commands/_template.ts'); + const commandTemplateFile = './src/commands/_template.ts'; + return files.filter(file => file !== commandTemplateFile); } } diff --git a/src/library/commands.ts b/src/library/commands.ts index 28f3abf..d426be3 100644 --- a/src/library/commands.ts +++ b/src/library/commands.ts @@ -1,22 +1,15 @@ -import glob from 'glob'; -import config from '../config'; -import CommandLoader, { ICommandClasses } from './commandLoader'; +import { ICommandClasses } from './commandLoader'; import Command from './iCommand'; -export const COMMANDS_PATH_GLOB = './src/commands/*.ts'; - // TODO: debateable whether we even need this wrapper class /** * @class Commands */ export default class Commands { - public readonly all: ICommandClasses; - private commandFiles: string[]; - constructor() { - this.commandFiles = glob.sync(COMMANDS_PATH_GLOB); - this.all = CommandLoader.getCommandClasses(this.commandFiles); + constructor(commandClasses: ICommandClasses) { + this.all = commandClasses; } get names() { @@ -27,15 +20,10 @@ export default class Commands { return this.all[commandName]; } - public longestName() { + public longestNameLength() { // Find the longest synopsis - return this.names.reduce((max, commandName) => { - commandName = `${config.messagePrefix}${commandName}`; - if (commandName.length + 1 > max) { - max = commandName.length + 1; - } - return max; - }, 0); + const longest = this.names.sort((a, b) => b.length - a.length)[0]; + return longest.length; } } diff --git a/src/library/core.ts b/src/library/core.ts index 1468237..4e942d8 100644 --- a/src/library/core.ts +++ b/src/library/core.ts @@ -1,11 +1,15 @@ - import { Client, GuildMember, Message, TextChannel } from 'discord.js'; +import glob from 'glob'; import config from '../config'; +import CommandLoader from './commandLoader'; import CommandParser from './commandParser'; import Commands from './commands'; const cmdParser = new CommandParser(config.messagePrefix); -const commands = new Commands(); +const commandsPathGlob = './src/commands/*.ts'; +const commandFiles = glob.sync(commandsPathGlob); +const commandClasses = CommandLoader.getCommandClasses(commandFiles); +const commands = new Commands(commandClasses); export default class Core { @@ -32,7 +36,10 @@ export default class Core { try { const command = commands.get(commandName); if (command) { - return command.execute(args, msg, this.client); + return command.execute(args, msg, { + client: this.client, + commands + }); } else { return channel.send(`Command not found: ${commandName}`); diff --git a/src/library/iCommand.ts b/src/library/iCommand.ts index 6391d68..41fe668 100644 --- a/src/library/iCommand.ts +++ b/src/library/iCommand.ts @@ -1,4 +1,5 @@ import { Client, Message } from "discord.js"; +import Commands from "./commands"; /** * An interface for all commands to extend, representing the API that all @@ -9,5 +10,8 @@ import { Client, Message } from "discord.js"; export default interface ICommand { readonly name: string; readonly description: string; - execute(args: string[], msg: Message, client?: Client): void; + execute(args: string[], msg: Message, extra?: { + client?: Client, + commands?: Commands + }): void; }