diff --git a/package-lock.json b/package-lock.json index e43fe4a..3a3b939 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hinomori-bot", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hinomori-bot", - "version": "0.0.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@discordjs/rest": "^0.3.0", @@ -25,6 +25,7 @@ "@types/chai": "^4.3.0", "@types/lodash": "^4.14.178", "@types/mocha": "^9.1.0", + "@types/sinon": "^10.0.11", "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "chai": "^4.3.6", @@ -33,6 +34,7 @@ "husky": "^7.0.4", "mocha": "^9.2.0", "prettier": "^2.5.1", + "sinon": "^13.0.1", "ts-node": "^10.4.0", "typescript": "^4.5.5" } @@ -518,6 +520,41 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.1.tgz", + "integrity": "sha512-Wp5vwlZ0lOqpSYGKqr53INws9HLkt6JDc/pDZcPf7bchQnrXJMXPns8CXx0hFikMSGSWfvtvvpb2gtMVfkWagA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", + "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -687,6 +724,21 @@ "@types/node": "*" } }, + "node_modules/@types/sinon": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.11.tgz", + "integrity": "sha512-dmZsHlBsKUtBpHriNjlK0ndlvEh8dcb9uV9Afsbt89QIyydpC7NcR+nWlAhASfy3GHnxTl4FX/aKE7XZUt/B4g==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", + "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", @@ -2450,6 +2502,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2551,6 +2609,12 @@ "semver": "bin/semver" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -2636,6 +2700,12 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2939,6 +3009,19 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -3093,6 +3176,15 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3498,6 +3590,24 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "optional": true }, + "node_modules/sinon": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.1.tgz", + "integrity": "sha512-8yx2wIvkBjIq/MGY1D9h1LMraYW+z1X0mb648KZnKSdvLasvDu7maa0dFaNYdTDczFgbjNw2tOmWdTk9saVfwQ==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^9.0.0", + "@sinonjs/samsam": "^6.1.1", + "diff": "^5.0.0", + "nise": "^5.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4474,6 +4584,41 @@ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==" }, + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.1.tgz", + "integrity": "sha512-Wp5vwlZ0lOqpSYGKqr53INws9HLkt6JDc/pDZcPf7bchQnrXJMXPns8CXx0hFikMSGSWfvtvvpb2gtMVfkWagA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/samsam": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", + "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4639,6 +4784,21 @@ "@types/node": "*" } }, + "@types/sinon": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.11.tgz", + "integrity": "sha512-dmZsHlBsKUtBpHriNjlK0ndlvEh8dcb9uV9Afsbt89QIyydpC7NcR+nWlAhASfy3GHnxTl4FX/aKE7XZUt/B4g==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", + "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", + "dev": true + }, "@types/ws": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", @@ -5910,6 +6070,12 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5997,6 +6163,12 @@ } } }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -6070,6 +6242,12 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -6314,6 +6492,19 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -6416,6 +6607,15 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -6695,6 +6895,20 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "optional": true }, + "sinon": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.1.tgz", + "integrity": "sha512-8yx2wIvkBjIq/MGY1D9h1LMraYW+z1X0mb648KZnKSdvLasvDu7maa0dFaNYdTDczFgbjNw2tOmWdTk9saVfwQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^9.0.0", + "@sinonjs/samsam": "^6.1.1", + "diff": "^5.0.0", + "nise": "^5.1.1", + "supports-color": "^7.2.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index 112b28c..5c62008 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "build/index.js", "scripts": { - "test": "mocha --recursive --extension ts --require ts-node/register", + "test": "LOG_LEVEL=error mocha --recursive --extension ts --require ts-node/register", "lint": "npm run lint:format && npm run lint:code", "lint:format": "prettier -c .", "lint:code": "eslint .", @@ -34,6 +34,7 @@ "@types/chai": "^4.3.0", "@types/lodash": "^4.14.178", "@types/mocha": "^9.1.0", + "@types/sinon": "^10.0.11", "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "chai": "^4.3.6", @@ -42,6 +43,7 @@ "husky": "^7.0.4", "mocha": "^9.2.0", "prettier": "^2.5.1", + "sinon": "^13.0.1", "ts-node": "^10.4.0", "typescript": "^4.5.5" } diff --git a/src/commander/command-factory.ts b/src/commander/command-factory.ts new file mode 100644 index 0000000..cf92ad0 --- /dev/null +++ b/src/commander/command-factory.ts @@ -0,0 +1,35 @@ +import { CommandInteraction } from "discord.js"; + +import { UserProfileStore } from "../store/user-profiles"; +import { UpdateProfile } from "../commands/user/profile/update"; +import { ActivateProfile } from "../commands/user/profile/activate"; +import { ListProfile } from "../commands/user/profile/list"; +import { RemoveProfile } from "../commands/user/profile/remove"; +import { ArrangePlayers } from "../commands/user/arrange"; + +export class CommandFactory { + constructor(private profileStore: UserProfileStore) {} + + newUpdateProfile(interaction: CommandInteraction): UpdateProfile { + return new UpdateProfile(interaction, this.profileStore); + } + + newListProfile(interaction: CommandInteraction): ListProfile { + return new ListProfile(interaction, this.profileStore); + } + + newActivateProfile(interaction: CommandInteraction): ActivateProfile { + return new ActivateProfile(interaction, this.profileStore); + } + + newRemoveProfile(interaction: CommandInteraction): RemoveProfile { + return new RemoveProfile(interaction, this.profileStore); + } + + newArrangePlayers( + interaction: CommandInteraction, + mention = true + ): ArrangePlayers { + return new ArrangePlayers(interaction, this.profileStore, mention); + } +} diff --git a/src/commands/commander.ts b/src/commander/commander.ts similarity index 93% rename from src/commands/commander.ts rename to src/commander/commander.ts index daf2ff3..3a8ed31 100644 --- a/src/commands/commander.ts +++ b/src/commander/commander.ts @@ -1,8 +1,8 @@ import { logger } from "./../logger"; import { Interaction, CommandInteraction } from "discord.js"; -import { CommandFactory } from "./factory"; +import { CommandFactory } from "./command-factory"; import { UserProfileStore } from "./../store/user-profiles"; -import { Command } from "./command"; +import { Command } from "../commands/command"; export class Commander { private factory: CommandFactory; @@ -58,7 +58,7 @@ export class Commander { logger.debug("executing command"); try { - await cmd.executeCommand(interaction); + await cmd.executeCommand(); } catch (error) { logger.error({ error }, "failed to execute command"); } diff --git a/src/commands/catch-execute-error.ts b/src/commands/catch-execute-error.ts new file mode 100644 index 0000000..c758c95 --- /dev/null +++ b/src/commands/catch-execute-error.ts @@ -0,0 +1,39 @@ +import { CommandInteraction } from "discord.js"; +import { logger } from "../logger"; +import { CommandError } from "./command-error"; +import { errorReplies, generalErrorMessage } from "./error-replies"; +import { ReplyFunc } from "./error-replies/reply-func"; +import { InteractiveCommand } from "./interactive-command"; + +type ExecuteCommandType = typeof InteractiveCommand.prototype.executeCommand; + +async function handleError( + err: Error, + interaction: CommandInteraction +): Promise { + if (!(err instanceof CommandError)) { + throw err; + } + + logger.info(err.data, err.message); + const getReplyMessage: ReplyFunc = + errorReplies[err.errorId] ?? generalErrorMessage; + await interaction.reply(getReplyMessage(err.data)); +} + +export function CatchExecuteError() { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const originalMethod: ExecuteCommandType = descriptor.value; + const newMethod: ExecuteCommandType = async function ( + this: InteractiveCommand + ) { + try { + return await originalMethod.apply(this); + } catch (err) { + await handleError(err, this.interaction); + } + }; + descriptor.value = newMethod; + }; +} diff --git a/src/commands/command-error.ts b/src/commands/command-error.ts new file mode 100644 index 0000000..44d42a3 --- /dev/null +++ b/src/commands/command-error.ts @@ -0,0 +1,5 @@ +export class CommandError extends Error { + constructor(msg: string, public data: T, public readonly errorId: string) { + super(msg); + } +} diff --git a/src/commands/command.ts b/src/commands/command.ts index ff4f0ba..bf63ad5 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -1,5 +1,3 @@ -import { CommandInteraction } from "discord.js"; - export interface Command { - executeCommand(interaction: CommandInteraction): Promise; + executeCommand(): Promise; } diff --git a/src/commands/error-replies/arrange.ts b/src/commands/error-replies/arrange.ts new file mode 100644 index 0000000..73be700 --- /dev/null +++ b/src/commands/error-replies/arrange.ts @@ -0,0 +1,31 @@ +import { InteractionReplyOptions, MessageMentionOptions } from "discord.js"; +import { + EmptyActiveProfilesErrorData, + EmptyProfilesErrorData, + errorIds, +} from "../user/arrange-errors"; +import { ReplyFunc } from "./reply-func"; + +export const errorReplies: Record = { + [errorIds.noEnoughPlayers]: (): InteractionReplyOptions => { + return { content: "玩家人數不足四人,不建議開協力 LIVE。" }; + }, + [errorIds.emptyProfiles]: ( + data: EmptyProfilesErrorData + ): InteractionReplyOptions => { + const userString = data.users.map((u) => `${u} (${u.username})`).join(" "); + const allowedMentions: MessageMentionOptions = data.mention + ? undefined + : { users: [] }; + return { content: `${userString} 沒有設定編組。`, allowedMentions }; + }, + [errorIds.emptyActiveProfiles]: ( + data: EmptyActiveProfilesErrorData + ): InteractionReplyOptions => { + const userString = data.users.map((u) => `${u} (${u.username})`).join(" "); + const allowedMentions: MessageMentionOptions = data.mention + ? undefined + : { users: [] }; + return { content: `${userString} 沒有設定使用中編組。`, allowedMentions }; + }, +}; diff --git a/src/commands/error-replies/index.ts b/src/commands/error-replies/index.ts new file mode 100644 index 0000000..1ba4090 --- /dev/null +++ b/src/commands/error-replies/index.ts @@ -0,0 +1,21 @@ +import { errorReplies as arrangeErrorReplies } from "./arrange"; +import { errorReplies as profileActivateErrorReplies } from "./profile-activate"; +import { errorReplies as profileListErrorReplies } from "./profile-list"; +import { errorReplies as profileRemoveErrorReplies } from "./profile-remove"; +import { errorReplies as profileUpdateErrorReplies } from "./profile-update"; +import { ReplyFunc } from "./reply-func"; + +export const errorReplies: Record = Object.assign( + {}, + arrangeErrorReplies, + profileActivateErrorReplies, + profileListErrorReplies, + profileRemoveErrorReplies, + profileUpdateErrorReplies +); + +export const generalErrorMessage: ReplyFunc = () => { + return { + content: "指令錯誤", + }; +}; diff --git a/src/commands/error-replies/profile-activate.ts b/src/commands/error-replies/profile-activate.ts new file mode 100644 index 0000000..3034825 --- /dev/null +++ b/src/commands/error-replies/profile-activate.ts @@ -0,0 +1,31 @@ +import { InteractionReplyOptions } from "discord.js"; +import { errorIds } from "../user/profile/activate-errors"; +import { ReplyFunc } from "./reply-func"; + +export const errorReplies: Record = { + [errorIds.emptyIndex]: (): InteractionReplyOptions => { + return { + content: "沒有輸入編號 (index)。", + }; + }, + [errorIds.indexNotANumber]: (): InteractionReplyOptions => { + return { + content: "輸入的編號 (index) 錯誤。", + }; + }, + [errorIds.indexOutOfRange]: (): InteractionReplyOptions => { + return { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }; + }, + [errorIds.noProfileRecord]: (): InteractionReplyOptions => { + return { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }; + }, + [errorIds.emptyProfile]: (): InteractionReplyOptions => { + return { + content: "選擇的編組是空白的。", + }; + }, +}; diff --git a/src/commands/error-replies/profile-list.ts b/src/commands/error-replies/profile-list.ts new file mode 100644 index 0000000..56b0824 --- /dev/null +++ b/src/commands/error-replies/profile-list.ts @@ -0,0 +1,16 @@ +import { InteractionReplyOptions } from "discord.js"; +import { errorIds } from "../user/profile/list-errors"; +import { ReplyFunc } from "./reply-func"; + +export const errorReplies: Record = { + [errorIds.noProfileRecord]: (): InteractionReplyOptions => { + return { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }; + }, + [errorIds.noValidProfile]: (): InteractionReplyOptions => { + return { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }; + }, +}; diff --git a/src/commands/error-replies/profile-remove.ts b/src/commands/error-replies/profile-remove.ts new file mode 100644 index 0000000..6766724 --- /dev/null +++ b/src/commands/error-replies/profile-remove.ts @@ -0,0 +1,31 @@ +import { InteractionReplyOptions } from "discord.js"; +import { errorIds } from "../user/profile/remove-errors"; +import { ReplyFunc } from "./reply-func"; + +export const errorReplies: Record = { + [errorIds.emptyIndex]: (): InteractionReplyOptions => { + return { + content: "沒有輸入編號 (index)。", + }; + }, + [errorIds.indexNotANumber]: (): InteractionReplyOptions => { + return { + content: "輸入的編號 (index) 錯誤。", + }; + }, + [errorIds.indexOutOfRange]: (): InteractionReplyOptions => { + return { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }; + }, + [errorIds.noProfileRecord]: (): InteractionReplyOptions => { + return { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }; + }, + [errorIds.emptyProfile]: (): InteractionReplyOptions => { + return { + content: "選擇的編組是空白的。", + }; + }, +}; diff --git a/src/commands/error-replies/profile-update.ts b/src/commands/error-replies/profile-update.ts new file mode 100644 index 0000000..b8786be --- /dev/null +++ b/src/commands/error-replies/profile-update.ts @@ -0,0 +1,38 @@ +import { InteractionReplyOptions } from "discord.js"; +import { errorIds } from "../user/profile/update-errors"; +import { ReplyFunc } from "./reply-func"; + +export const errorReplies: Record = { + [errorIds.indexNotANumber]: (): InteractionReplyOptions => { + return { + content: "輸入的編號 (index) 錯誤。", + }; + }, + [errorIds.indexOutOfRange]: (): InteractionReplyOptions => { + return { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }; + }, + [errorIds.invalidOptionType]: (): InteractionReplyOptions => { + return { + content: "輸入的編組類型 (type) 錯誤。僅能選擇跑者或幫手其中一種。", + }; + }, + [errorIds.invalidOptionPower]: (): InteractionReplyOptions => { + return { + content: "輸入的綜合力 (power) 格式錯誤。", + }; + }, + [errorIds.optionPowerOutOfRange]: (): InteractionReplyOptions => { + return { + content: + "輸入的綜合力 (power) 過低或過高。請輸入完整數字,而不是以萬為單位的簡寫。", + }; + }, + [errorIds.invalidOptionCards]: (): InteractionReplyOptions => { + return { + content: + "輸入的卡片倍率 (cards) 格式錯誤。請以逗號 , 分隔,不要使用空白分隔。", + }; + }, +}; diff --git a/src/commands/error-replies/reply-func.ts b/src/commands/error-replies/reply-func.ts new file mode 100644 index 0000000..7596b39 --- /dev/null +++ b/src/commands/error-replies/reply-func.ts @@ -0,0 +1,4 @@ +import { InteractionReplyOptions } from "discord.js"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type ReplyFunc = (data?: any) => InteractionReplyOptions; diff --git a/src/commands/factory.ts b/src/commands/factory.ts deleted file mode 100644 index 4e6f777..0000000 --- a/src/commands/factory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UpdateProfile } from "./profile/update"; -import { UserProfileStore } from "../store/user-profiles"; -import { CommandInteraction } from "discord.js"; -import { ActivateProfile } from "./profile/activate"; -import { ListProfile } from "./profile/list"; -import { RemoveProfile } from "./profile/remove"; -import { ArrangePlayers } from "./arrange"; - -export class CommandFactory { - constructor(private profileStore: UserProfileStore) {} - - newUpdateProfile(interaction: CommandInteraction): UpdateProfile { - return new UpdateProfile(this.profileStore, interaction); - } - - newListProfile(interaction: CommandInteraction): ListProfile { - return new ListProfile(this.profileStore, interaction); - } - - newActivateProfile(interaction: CommandInteraction): ActivateProfile { - return new ActivateProfile(this.profileStore, interaction); - } - - newRemoveProfile(interaction: CommandInteraction): RemoveProfile { - return new RemoveProfile(this.profileStore, interaction); - } - - newArrangePlayers( - interaction: CommandInteraction, - mention = true - ): ArrangePlayers { - return new ArrangePlayers(this.profileStore, interaction, mention); - } -} diff --git a/src/commands/interactive-command.ts b/src/commands/interactive-command.ts new file mode 100644 index 0000000..e28f570 --- /dev/null +++ b/src/commands/interactive-command.ts @@ -0,0 +1,7 @@ +import { CommandInteraction } from "discord.js"; +import { Command } from "./command"; + +export abstract class InteractiveCommand implements Command { + constructor(protected interaction: CommandInteraction) {} + abstract executeCommand(): Promise; +} diff --git a/src/commands/profile/activate.ts b/src/commands/profile/activate.ts deleted file mode 100644 index 38f0091..0000000 --- a/src/commands/profile/activate.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Command } from "./../command"; -import { formatUserProfile } from "./../../models/user-profile"; -import { CommandInteraction } from "discord.js"; -import { UserProfileStore } from "./../../store/user-profiles"; -import { logger } from "../../logger"; -import { logUser } from "../../utils/log-user"; - -const errParseOptions = new Error("failed to parse options"); - -interface ActivateProfileOptions { - index: number; -} - -export class ActivateProfile implements Command { - constructor( - private profileStore: UserProfileStore, - private interaction: CommandInteraction - ) {} - - private async badRequest() { - logger.info({ reason: "bad request" }, "activate failed"); - await this.interaction.reply("格式不正確。"); - } - - private async noRecord() { - logger.info({ reason: "user record not found" }, "activate failed"); - await this.interaction.reply( - "沒有編組資料。請先使用 /profile update 指令新增編組。" - ); - } - - private async emptyProfileSelected() { - logger.info({ reason: "selected profile is empty" }, "activate failed"); - await this.interaction.reply("選擇的編組是空白的。"); - } - - private async parseOptions(): Promise { - const index = this.interaction.options.getNumber("index"); - if (typeof index !== "number" || isNaN(index) || index < 1 || index > 10) { - throw errParseOptions; - } - - return { index }; - } - - async executeCommand(): Promise { - logger.debug("activate profile"); - let options: ActivateProfileOptions; - try { - options = await this.parseOptions(); - } catch (e) { - if (e === errParseOptions) { - logger.warn({ command: this.interaction.toString() }, e.message); - return await this.badRequest(); - } - throw e; - } - const { index } = options; - const { user } = this.interaction; - logger.debug( - { options: { index }, user: logUser(user) }, - "activate profile options" - ); - - const record = await this.profileStore.get(user.id); - if (!record) { - return await this.noRecord(); - } - - const i = index - 1; - const profile = record.profiles[i]; - if (profile == null) { - return await this.emptyProfileSelected(); - } - - const newRecord: typeof record = { ...record, active: i }; - await this.profileStore.set(user.id, newRecord); - - logger.info({ user: user.id }, "profile activated"); - await this.interaction.reply( - [ - `已更新使用中編組編號。使用中編組:`, - "```", - `${index}: ${formatUserProfile(profile)}`, - "```", - ].join("\n") - ); - } -} diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts deleted file mode 100644 index 219be6a..0000000 --- a/src/commands/profile/list.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Command } from "./../command"; -import { formatUserProfileRecord } from "./../../models/user-profile"; -import { CommandInteraction, User } from "discord.js"; -import { UserProfileStore } from "./../../store/user-profiles"; -import { logger } from "../../logger"; -import { logUser } from "../../utils/log-user"; - -const errParseOptions = new Error("failed to parse options"); - -interface ListProfileOptions { - user: User; -} - -export class ListProfile implements Command { - constructor( - private profileStore: UserProfileStore, - private interaction: CommandInteraction - ) {} - - private async badRequest() { - logger.info({ reason: "bad request" }, "list failed"); - await this.interaction.reply("格式不正確。"); - } - - private async noProfile() { - logger.info({ reason: "no valid profile" }, "list failed"); - await this.interaction.reply( - "沒有編組資料。請先使用 /profile update 指令新增編組。" - ); - } - - private async parseOptions(): Promise { - let user = this.interaction.options.getUser("user"); - if (user == null) { - user = this.interaction.user; - } - - return { user }; - } - - async executeCommand(): Promise { - logger.debug("list profile"); - let options: ListProfileOptions; - try { - options = await this.parseOptions(); - } catch (e) { - if (e === errParseOptions) { - logger.warn({ command: this.interaction.toString() }, e.message); - return await this.badRequest(); - } - throw e; - } - const { user: targetUser } = options; - const { user } = this.interaction; - logger.debug( - { options: { user: logUser(targetUser) }, user: logUser(user) }, - "list profile options" - ); - - const record = await this.profileStore.get(targetUser.id); - if (!record || record.profiles.every((p) => p == null)) { - return await this.noProfile(); - } - - logger.info( - { user: logUser(user), targetUser: logUser(targetUser) }, - "profile listed" - ); - const userString = `${targetUser.username} (${targetUser})`; - await this.interaction.reply({ - content: [ - `${userString} 的編組資料:`, - "```", - formatUserProfileRecord(record), - "```", - ].join("\n"), - allowedMentions: { users: [] }, - }); - } -} diff --git a/src/commands/profile/remove.ts b/src/commands/profile/remove.ts deleted file mode 100644 index 4ec053f..0000000 --- a/src/commands/profile/remove.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Command } from "./../command"; -import { formatUserProfileRecord } from "./../../models/user-profile"; -import { CommandInteraction } from "discord.js"; -import { UserProfileStore } from "./../../store/user-profiles"; -import { logger } from "../../logger"; -import { logUser } from "../../utils/log-user"; - -const errParseOptions = new Error("failed to parse options"); - -interface RemoveProfileOptions { - index: number; -} - -export class RemoveProfile implements Command { - constructor( - private profileStore: UserProfileStore, - private interaction: CommandInteraction - ) {} - - private async badRequest() { - logger.info({ reason: "bad request" }, "remove failed"); - await this.interaction.reply("格式不正確。"); - } - - private async noRecord() { - logger.info({ reason: "user record not found" }, "activate failed"); - await this.interaction.reply( - "沒有編組資料。請先使用 /profile update 指令新增編組。" - ); - } - - private async emptyProfileSelected() { - logger.info({ reason: "selected profile is empty" }, "remove failed"); - await this.interaction.reply("選擇的編組是空白的。"); - } - - private async parseOptions(): Promise { - const index = this.interaction.options.getNumber("index"); - if (typeof index !== "number" || isNaN(index) || index < 1 || index > 10) { - throw errParseOptions; - } - - return { index }; - } - - async executeCommand(): Promise { - logger.debug("remove profile"); - let options: RemoveProfileOptions; - try { - options = await this.parseOptions(); - } catch (e) { - if (e === errParseOptions) { - logger.warn({ command: this.interaction.toString() }, e.message); - return await this.badRequest(); - } - throw e; - } - const { index } = options; - const { user } = this.interaction; - logger.debug( - { options: { index }, user: logUser(user) }, - "remove profile options" - ); - - const record = await this.profileStore.get(user.id); - if (!record || record.profiles.every((p) => p == null)) { - return await this.noRecord(); - } - - const i = index - 1; - const profile = record.profiles[i]; - if (profile == null) { - return await this.emptyProfileSelected(); - } - - const newProfiles = [...record.profiles]; - newProfiles[i] = null; - - const newRecord: typeof record = { ...record, profiles: newProfiles }; - await this.profileStore.set(user.id, newRecord); - - logger.info({ user: logUser(user) }, "profile removed"); - await this.interaction.reply( - [ - `已移除選擇的編組。你的編組資料:`, - "```", - formatUserProfileRecord(newRecord), - "```", - ].join("\n") - ); - } -} diff --git a/src/commands/user/arrange-errors.ts b/src/commands/user/arrange-errors.ts new file mode 100644 index 0000000..6675bdf --- /dev/null +++ b/src/commands/user/arrange-errors.ts @@ -0,0 +1,71 @@ +import { User } from "discord.js"; +import { CommandError } from "../command-error"; + +const arrangeFailed = "arrange failed"; + +const noEnoughPlayers = "arrange-noEnoughPlayers"; + +export interface PlayerNotEnoughErrorData { + reason: string; + count: number; +} + +export class PlayerNotEnoughError extends CommandError { + constructor(count: number) { + const data: PlayerNotEnoughErrorData = { + reason: "no enough players", + count, + }; + super(arrangeFailed, data, PlayerNotEnoughError.id); + } + + static readonly id = noEnoughPlayers; +} + +const emptyProfiles = "arrange-emptyProfiles"; + +export interface EmptyProfilesErrorData { + reason: string; + users: User[]; + mention: boolean; +} + +export class EmptyProfilesError extends CommandError { + constructor(users: User[], mention: boolean) { + const data: EmptyProfilesErrorData = { + reason: "empty profile records", + users, + mention, + }; + super(arrangeFailed, data, EmptyProfilesError.id); + } + + static readonly id = emptyProfiles; +} + +const emptyActiveProfiles = "arrange-emptyActiveProfiles"; + +export interface EmptyActiveProfilesErrorData { + reason: string; + users: User[]; + mention: boolean; +} + +export class EmptyActiveProfilesError extends CommandError { + constructor(users: User[], mention: boolean) { + const data: EmptyActiveProfilesErrorData = { + reason: "empty active profiles", + users, + mention, + }; + super(arrangeFailed, data, EmptyActiveProfilesError.id); + } + + static readonly id = emptyActiveProfiles; +} + +export const errorIds = { + noEnoughPlayers, + emptyProfiles, + emptyActiveProfiles, +}; diff --git a/src/commands/arrange.ts b/src/commands/user/arrange.ts similarity index 57% rename from src/commands/arrange.ts rename to src/commands/user/arrange.ts index 6596b91..90be356 100644 --- a/src/commands/arrange.ts +++ b/src/commands/user/arrange.ts @@ -1,60 +1,32 @@ -import { Command } from "./command"; import { CommandInteraction, MessageMentionOptions, User } from "discord.js"; -import { UserProfileStore } from "./../store/user-profiles"; -import { logger } from "../logger"; +import { UserProfileStore } from "../../store/user-profiles"; +import { logger } from "../../logger"; import { formatUserProfile, UserProfile, UserProfileRecord, -} from "../models/user-profile"; -import { logUser } from "../utils/log-user"; -import { polePosition } from "../models/pole-position"; - -const errParseOptions = new Error("failed to parse options"); +} from "../../models/user-profile"; +import { logUser } from "../../utils/log-user"; +import { polePosition } from "../../models/pole-position"; +import { InteractiveCommand } from "../interactive-command"; +import { CatchExecuteError } from "../catch-execute-error"; +import { + EmptyActiveProfilesError, + EmptyProfilesError, + PlayerNotEnoughError, +} from "./arrange-errors"; interface ArrangePlayersOptions { players: User[]; } -class EmptyProfilesError extends Error { - private static message = "empty profiles"; - constructor(public users: User[]) { - super(EmptyProfilesError.message); - } -} - -export class ArrangePlayers implements Command { +export class ArrangePlayers extends InteractiveCommand { constructor( + interaction: CommandInteraction, private profileStore: UserProfileStore, - private interaction: CommandInteraction, private mention: boolean - ) {} - - private async badRequest() { - logger.info({ reason: "bad request" }, "arrange failed"); - await this.interaction.reply("格式不正確。"); - } - - private async playerNotEnough() { - logger.info({ reason: "no enough players" }, "arrange failed"); - await this.interaction.reply("玩家人數不足四人,不建議開協力 LIVE。"); - } - - private async playersDoNotHaveActiveProfile(players: User[]) { - logger.info( - { - reason: "some players do not have valid active profile", - who: logUser(players), - }, - "arrange failed" - ); - const userString = players.map((u) => `${u} (${u.username})`).join(" "); - await this.interaction.reply({ - content: `${userString} 沒有設定編組。請檢查使用者是否已新增編組、使用中編組設定是否正確。`, - allowedMentions: { - users: [], - }, - }); + ) { + super(interaction); } private checkEmptyRecords(records: UserProfileRecord[]): number[] | null { @@ -85,7 +57,7 @@ export class ArrangePlayers implements Command { (n) => players[n] ); if (emptyRecordPlayers != null) { - throw new EmptyProfilesError(emptyRecordPlayers); + throw new EmptyProfilesError(emptyRecordPlayers, this.mention); } const playerProfiles = playerRecords.map((p) => p.profiles[p.active]); @@ -93,12 +65,18 @@ export class ArrangePlayers implements Command { (n) => players[n] ); if (emptyProfilePlayers != null) { - throw new EmptyProfilesError(emptyProfilePlayers); + throw new EmptyActiveProfilesError(emptyProfilePlayers, this.mention); } return playerProfiles; } + private checkPlayerCount(players: User[]) { + if (players.length < 4) { + throw new PlayerNotEnoughError(players.length); + } + } + private async parseOptions(): Promise { const players = Array(5) .fill(undefined) @@ -107,18 +85,11 @@ export class ArrangePlayers implements Command { return { players }; } + @CatchExecuteError() async executeCommand(): Promise { logger.debug("arrange players"); - let options: ArrangePlayersOptions; - try { - options = await this.parseOptions(); - } catch (e) { - if (e === errParseOptions) { - logger.warn({ command: this.interaction.toString() }, e.message); - return await this.badRequest(); - } - throw e; - } + const options = await this.parseOptions(); + const { players } = options; const { user } = this.interaction; logger.debug( @@ -130,20 +101,9 @@ export class ArrangePlayers implements Command { "arrange players options" ); - if (players.length < 4) { - return await this.playerNotEnough(); - } - - let profiles: UserProfile[]; - try { - profiles = await this.getActiveUserProfiles(players); - } catch (err) { - if (err instanceof EmptyProfilesError) { - return await this.playersDoNotHaveActiveProfile(err.users); - } - throw err; - } + this.checkPlayerCount(players); + const profiles = await this.getActiveUserProfiles(players); const position = polePosition(profiles); const skill6Player = profiles.reduce( (p, c, i, arr) => (arr[p].power > c.power ? p : i), diff --git a/src/commands/user/profile/activate-errors.ts b/src/commands/user/profile/activate-errors.ts new file mode 100644 index 0000000..d9c4c10 --- /dev/null +++ b/src/commands/user/profile/activate-errors.ts @@ -0,0 +1,96 @@ +import { CommandError } from "../../command-error"; + +const activateFailed = "activate profile failed"; + +const emptyIndex = "profileActivate-emptyIndex"; + +export interface EmptyIndexErrorData { + reason: string; +} + +export class EmptyIndexError extends CommandError { + constructor() { + const data: EmptyIndexErrorData = { + reason: "index is empty", + }; + super(activateFailed, data, EmptyIndexError.id); + } + + static readonly id = emptyIndex; +} + +const indexNotANumber = "profileActivate-indexNotANumber"; + +export interface IndexNotANumberErrorData { + reason: string; +} + +export class IndexNotANumberError extends CommandError { + constructor() { + const data: IndexNotANumberErrorData = { + reason: "index is not a number", + }; + super(activateFailed, data, IndexNotANumberError.id); + } + + static readonly id = indexNotANumber; +} + +const indexOutOfRange = "profileActivate-indexOutOfRange"; + +export interface IndexOutOfRangeErrorData { + reason: string; +} + +export class IndexOutOfRangeError extends CommandError { + constructor() { + const data: IndexOutOfRangeErrorData = { + reason: "index out of range", + }; + super(activateFailed, data, IndexOutOfRangeError.id); + } + + static readonly id = indexOutOfRange; +} + +const noProfileRecord = "profileActivate-noProfileRecord"; + +export interface NoProfileRecordErrorData { + reason: string; +} + +export class NoProfileRecordError extends CommandError { + constructor() { + const data: NoProfileRecordErrorData = { + reason: "user does not have profile record", + }; + super(activateFailed, data, NoProfileRecordError.id); + } + + static readonly id = noProfileRecord; +} + +const emptyProfile = "profileActivate-emptyProfile"; + +export interface EmptyProfileErrorData { + reason: string; +} + +export class EmptyProfileError extends CommandError { + constructor() { + const data: EmptyProfileErrorData = { + reason: "empty profile selected", + }; + super(activateFailed, data, EmptyProfileError.id); + } + + static readonly id = emptyProfile; +} + +export const errorIds = { + emptyIndex, + indexNotANumber, + indexOutOfRange, + noProfileRecord, + emptyProfile, +}; diff --git a/src/commands/user/profile/activate.ts b/src/commands/user/profile/activate.ts new file mode 100644 index 0000000..8eb22a8 --- /dev/null +++ b/src/commands/user/profile/activate.ts @@ -0,0 +1,84 @@ +import { formatUserProfile } from "../../../models/user-profile"; +import { CommandInteraction, User } from "discord.js"; +import { UserProfileStore } from "../../../store/user-profiles"; +import { logger } from "../../../logger"; +import { logUser } from "../../../utils/log-user"; +import { InteractiveCommand } from "../../interactive-command"; +import { CatchExecuteError } from "../../catch-execute-error"; +import { isNil } from "lodash"; +import { + EmptyIndexError, + EmptyProfileError, + IndexNotANumberError, + IndexOutOfRangeError, + NoProfileRecordError, +} from "./activate-errors"; + +interface ActivateProfileOptions { + index: number; +} + +export class ActivateProfile extends InteractiveCommand { + constructor( + interaction: CommandInteraction, + private profileStore: UserProfileStore + ) { + super(interaction); + } + + private async getUserProfileRecord(user: User) { + const record = await this.profileStore.get(user.id); + if (!record) { + throw new NoProfileRecordError(); + } + return record; + } + + private async parseOptions(): Promise { + const index = this.interaction.options.getNumber("index"); + if (isNil(index)) { + throw new EmptyIndexError(); + } + if (typeof index !== "number" || isNaN(index)) { + throw new IndexNotANumberError(); + } + if (index < 1 || index > 10 || !Number.isInteger(index)) { + throw new IndexOutOfRangeError(); + } + + return { index }; + } + + @CatchExecuteError() + async executeCommand(): Promise { + logger.debug("activate profile"); + const options = await this.parseOptions(); + + const { index } = options; + const { user } = this.interaction; + logger.debug( + { options: { index }, user: logUser(user) }, + "activate profile options" + ); + + const record = await this.getUserProfileRecord(user); + const i = index - 1; + const profile = record.profiles[i]; + if (isNil(profile)) { + throw new EmptyProfileError(); + } + + const newRecord: typeof record = { ...record, active: i }; + await this.profileStore.set(user.id, newRecord); + + logger.info({ user: user.id }, "profile activated"); + await this.interaction.reply( + [ + `已更新使用中編組編號。使用中編組:`, + "```", + `${index}: ${formatUserProfile(profile)}`, + "```", + ].join("\n") + ); + } +} diff --git a/src/commands/user/profile/list-errors.ts b/src/commands/user/profile/list-errors.ts new file mode 100644 index 0000000..43f3099 --- /dev/null +++ b/src/commands/user/profile/list-errors.ts @@ -0,0 +1,42 @@ +import { CommandError } from "../../command-error"; + +const listFailed = "list profile failed"; + +const noProfileRecord = "profileList-noProfileRecord"; + +export interface NoProfileRecordErrorData { + reason: string; +} + +export class NoProfileRecordError extends CommandError { + constructor() { + const data: NoProfileRecordErrorData = { + reason: "user does not have profile record", + }; + super(listFailed, data, NoProfileRecordError.id); + } + + static readonly id = noProfileRecord; +} + +const noValidProfile = "profileList-noValidProfile"; + +export interface NoValidProfileErrorData { + reason: string; +} + +export class NoValidProfileError extends CommandError { + constructor() { + const data: NoValidProfileErrorData = { + reason: "user does not have any valid profile", + }; + super(listFailed, data, NoValidProfileError.id); + } + + static readonly id = noValidProfile; +} + +export const errorIds = { + noProfileRecord, + noValidProfile, +}; diff --git a/src/commands/user/profile/list.ts b/src/commands/user/profile/list.ts new file mode 100644 index 0000000..26c4c5f --- /dev/null +++ b/src/commands/user/profile/list.ts @@ -0,0 +1,72 @@ +import { formatUserProfileRecord } from "../../../models/user-profile"; +import { CommandInteraction, User } from "discord.js"; +import { UserProfileStore } from "../../../store/user-profiles"; +import { logger } from "../../../logger"; +import { logUser } from "../../../utils/log-user"; +import { InteractiveCommand } from "../../interactive-command"; +import { CatchExecuteError } from "../../catch-execute-error"; +import { NoProfileRecordError, NoValidProfileError } from "./list-errors"; +import { isNil } from "lodash"; + +interface ListProfileOptions { + user: User; +} + +export class ListProfile extends InteractiveCommand { + constructor( + interaction: CommandInteraction, + private profileStore: UserProfileStore + ) { + super(interaction); + } + + private async getUserProfileRecord(user: User) { + const record = await this.profileStore.get(user.id); + if (!record) { + throw new NoProfileRecordError(); + } + if (record.profiles.every((p) => isNil(p))) { + throw new NoValidProfileError(); + } + return record; + } + + private async parseOptions(): Promise { + let user = this.interaction.options.getUser("user"); + if (user == null) { + user = this.interaction.user; + } + + return { user }; + } + + @CatchExecuteError() + async executeCommand(): Promise { + logger.debug("list profile"); + const options = await this.parseOptions(); + + const { user: targetUser } = options; + const { user } = this.interaction; + logger.debug( + { options: { user: logUser(targetUser) }, user: logUser(user) }, + "list profile options" + ); + + const record = await this.getUserProfileRecord(targetUser); + + logger.info( + { user: logUser(user), targetUser: logUser(targetUser) }, + "profile listed" + ); + const userString = `${targetUser.username} (${targetUser})`; + await this.interaction.reply({ + content: [ + `${userString} 的編組資料:`, + "```", + formatUserProfileRecord(record), + "```", + ].join("\n"), + allowedMentions: { users: [] }, + }); + } +} diff --git a/src/commands/user/profile/remove-errors.ts b/src/commands/user/profile/remove-errors.ts new file mode 100644 index 0000000..a9e4f64 --- /dev/null +++ b/src/commands/user/profile/remove-errors.ts @@ -0,0 +1,96 @@ +import { CommandError } from "../../command-error"; + +const removeFailed = "remove profile failed"; + +const emptyIndex = "profileRemove-emptyIndex"; + +export interface EmptyIndexErrorData { + reason: string; +} + +export class EmptyIndexError extends CommandError { + constructor() { + const data: EmptyIndexErrorData = { + reason: "index is empty", + }; + super(removeFailed, data, EmptyIndexError.id); + } + + static readonly id = emptyIndex; +} + +const indexNotANumber = "profileRemove-indexNotANumber"; + +export interface IndexNotANumberErrorData { + reason: string; +} + +export class IndexNotANumberError extends CommandError { + constructor() { + const data: IndexNotANumberErrorData = { + reason: "index is not a number", + }; + super(removeFailed, data, IndexNotANumberError.id); + } + + static readonly id = indexNotANumber; +} + +const indexOutOfRange = "profileRemove-indexOutOfRange"; + +export interface IndexOutOfRangeErrorData { + reason: string; +} + +export class IndexOutOfRangeError extends CommandError { + constructor() { + const data: IndexOutOfRangeErrorData = { + reason: "index out of range", + }; + super(removeFailed, data, IndexOutOfRangeError.id); + } + + static readonly id = indexOutOfRange; +} + +const noProfileRecord = "profileRemove-noProfileRecord"; + +export interface NoProfileRecordErrorData { + reason: string; +} + +export class NoProfileRecordError extends CommandError { + constructor() { + const data: NoProfileRecordErrorData = { + reason: "user does not have profile record", + }; + super(removeFailed, data, NoProfileRecordError.id); + } + + static readonly id = noProfileRecord; +} + +const emptyProfile = "profileRemove-emptyProfile"; + +export interface EmptyProfileErrorData { + reason: string; +} + +export class EmptyProfileError extends CommandError { + constructor() { + const data: EmptyProfileErrorData = { + reason: "empty profile selected", + }; + super(removeFailed, data, EmptyProfileError.id); + } + + static readonly id = emptyProfile; +} + +export const errorIds = { + emptyIndex, + indexNotANumber, + indexOutOfRange, + noProfileRecord, + emptyProfile, +}; diff --git a/src/commands/user/profile/remove.ts b/src/commands/user/profile/remove.ts new file mode 100644 index 0000000..8c92e75 --- /dev/null +++ b/src/commands/user/profile/remove.ts @@ -0,0 +1,86 @@ +import { formatUserProfileRecord } from "../../../models/user-profile"; +import { CommandInteraction, User } from "discord.js"; +import { UserProfileStore } from "../../../store/user-profiles"; +import { logger } from "../../../logger"; +import { logUser } from "../../../utils/log-user"; +import { InteractiveCommand } from "../../interactive-command"; +import { isNil } from "lodash"; +import { + EmptyIndexError, + EmptyProfileError, + IndexNotANumberError, + IndexOutOfRangeError, + NoProfileRecordError, +} from "./remove-errors"; +import { CatchExecuteError } from "../../catch-execute-error"; + +interface RemoveProfileOptions { + index: number; +} + +export class RemoveProfile extends InteractiveCommand { + constructor( + interaction: CommandInteraction, + private profileStore: UserProfileStore + ) { + super(interaction); + } + + private async getUserProfileRecord(user: User) { + const record = await this.profileStore.get(user.id); + if (!record) { + throw new NoProfileRecordError(); + } + return record; + } + + private async parseOptions(): Promise { + const index = this.interaction.options.getNumber("index"); + if (isNil(index)) { + throw new EmptyIndexError(); + } + if (typeof index !== "number" || isNaN(index)) { + throw new IndexNotANumberError(); + } + if (index < 1 || index > 10 || !Number.isInteger(index)) { + throw new IndexOutOfRangeError(); + } + + return { index }; + } + + @CatchExecuteError() + async executeCommand(): Promise { + logger.debug("remove profile"); + const options = await this.parseOptions(); + + const { index } = options; + const { user } = this.interaction; + logger.debug( + { options: { index }, user: logUser(user) }, + "remove profile options" + ); + + const record = await this.getUserProfileRecord(user); + const i = index - 1; + const profile = record.profiles[i]; + if (isNil(profile)) { + throw new EmptyProfileError(); + } + + const newProfiles = [...record.profiles]; + newProfiles[i] = null; + const newRecord: typeof record = { ...record, profiles: newProfiles }; + await this.profileStore.set(user.id, newRecord); + + logger.info({ user: logUser(user) }, "profile removed"); + await this.interaction.reply( + [ + `已移除選擇的編組。你的編組資料:`, + "```", + formatUserProfileRecord(newRecord), + "```", + ].join("\n") + ); + } +} diff --git a/src/commands/user/profile/update-errors.ts b/src/commands/user/profile/update-errors.ts new file mode 100644 index 0000000..cfb3797 --- /dev/null +++ b/src/commands/user/profile/update-errors.ts @@ -0,0 +1,114 @@ +import { CommandError } from "../../command-error"; + +const updateFailed = "update profile failed"; + +const indexNotANumber = "profileUpdate-indexNotANumber"; + +export interface IndexNotANumberErrorData { + reason: string; +} + +export class IndexNotANumberError extends CommandError { + constructor() { + const data: IndexNotANumberErrorData = { + reason: "index is not a number", + }; + super(updateFailed, data, IndexNotANumberError.id); + } + + static readonly id = indexNotANumber; +} + +const indexOutOfRange = "profileUpdate-indexOutOfRange"; + +export interface IndexOutOfRangeErrorData { + reason: string; +} + +export class IndexOutOfRangeError extends CommandError { + constructor() { + const data: IndexOutOfRangeErrorData = { + reason: "index out of range", + }; + super(updateFailed, data, IndexOutOfRangeError.id); + } + + static readonly id = indexOutOfRange; +} + +const invalidOptionType = "profileUpdate-invalidOptionType"; + +export interface InvalidOptionTypeErrorData { + reason: string; +} + +export class InvalidOptionTypeError extends CommandError { + constructor() { + const data: InvalidOptionTypeErrorData = { + reason: "option type is invalid", + }; + super(updateFailed, data, InvalidOptionTypeError.id); + } + + static readonly id = invalidOptionType; +} + +const invalidOptionPower = "profileUpdate-invalidOptionPower"; + +export interface InvalidOptionPowerErrorData { + reason: string; +} + +export class InvalidOptionPowerError extends CommandError { + constructor() { + const data: InvalidOptionPowerErrorData = { + reason: "option power is invalid", + }; + super(updateFailed, data, InvalidOptionPowerError.id); + } + + static readonly id = invalidOptionPower; +} + +const optionPowerOutOfRange = "profileUpdate-optionPowerOutOfRange"; + +export interface OptionPowerOutOfRangeErrorData { + reason: string; +} + +export class OptionPowerOutOfRangeError extends CommandError { + constructor() { + const data: OptionPowerOutOfRangeErrorData = { + reason: "option power out of range", + }; + super(updateFailed, data, OptionPowerOutOfRangeError.id); + } + + static readonly id = optionPowerOutOfRange; +} + +const invalidOptionCards = "profileUpdate-invalidOptionCards"; + +export interface invalidOptionCardsErrorData { + reason: string; +} + +export class InvalidOptionCardsError extends CommandError { + constructor() { + const data: invalidOptionCardsErrorData = { + reason: "option cards is invalid", + }; + super(updateFailed, data, InvalidOptionCardsError.id); + } + + static readonly id = invalidOptionCards; +} + +export const errorIds = { + indexNotANumber, + indexOutOfRange, + invalidOptionType, + invalidOptionPower, + optionPowerOutOfRange, + invalidOptionCards, +}; diff --git a/src/commands/profile/update.ts b/src/commands/user/profile/update.ts similarity index 52% rename from src/commands/profile/update.ts rename to src/commands/user/profile/update.ts index 91c9de0..670cae7 100644 --- a/src/commands/profile/update.ts +++ b/src/commands/user/profile/update.ts @@ -1,19 +1,27 @@ -import { Command } from "./../command"; import { formatUserProfileRecord, UserProfile, -} from "./../../models/user-profile"; +} from "./../../../models/user-profile"; import { CommandInteraction } from "discord.js"; import { convertToUserProfileType, UserProfileType, -} from "../../models/user-profile"; -import { UserProfileStore } from "./../../store/user-profiles"; -import { logger } from "../../logger"; -import { logUser } from "../../utils/log-user"; -import { profileRatio } from "../../models/profile-ratio"; - -const errParseOptions = new Error("failed to parse options"); +} from "../../../models/user-profile"; +import { UserProfileStore } from "../../../store/user-profiles"; +import { logger } from "../../../logger"; +import { logUser } from "../../../utils/log-user"; +import { profileRatio } from "../../../models/profile-ratio"; +import { InteractiveCommand } from "../../interactive-command"; +import { CatchExecuteError } from "../../catch-execute-error"; +import { + IndexNotANumberError, + IndexOutOfRangeError, + InvalidOptionCardsError, + InvalidOptionPowerError, + InvalidOptionTypeError, + OptionPowerOutOfRangeError, +} from "./update-errors"; +import { isNil } from "lodash"; interface UpdateProfileOptions { type: UserProfileType; @@ -22,21 +30,18 @@ interface UpdateProfileOptions { index: number; } -export class UpdateProfile implements Command { +export class UpdateProfile extends InteractiveCommand { constructor( - private profileStore: UserProfileStore, - private interaction: CommandInteraction - ) {} - - private async badRequest() { - logger.info({ reason: "bad request" }, "update failed"); - await this.interaction.reply("格式不正確。"); + interaction: CommandInteraction, + private profileStore: UserProfileStore + ) { + super(interaction); } - private async parseOptions(): Promise { + private parseOptionType(): UserProfileType { const typeString = this.interaction.options.getString("type"); if (typeof typeString !== "string") { - throw errParseOptions; + throw new InvalidOptionTypeError(); } let type: UserProfileType; @@ -44,34 +49,61 @@ export class UpdateProfile implements Command { type = convertToUserProfileType(typeString); } catch (e) { logger.error(e); - throw errParseOptions; + throw new InvalidOptionTypeError(); } + return type; + } + private parseOptionCards(): number { const cardsString = this.interaction.options.getString("cards"); if (typeof cardsString !== "string") { - throw errParseOptions; + throw new InvalidOptionCardsError(); } const cardRatioStrings = cardsString.split(","); if (cardRatioStrings.length !== 5) { - throw errParseOptions; + throw new InvalidOptionCardsError(); } const cards = cardRatioStrings.map((s) => parseInt(s, 10)); if (cards.some((n) => typeof n !== "number" || isNaN(n))) { - throw errParseOptions; + throw new InvalidOptionCardsError(); } + return profileRatio(cards); + } - const ratio = profileRatio(cards); - + private parseOptionPower(): number { const power = this.interaction.options.getNumber("power"); if (typeof power !== "number" || isNaN(power)) { - throw errParseOptions; + throw new InvalidOptionPowerError(); + } + if (power < 10000 || power > 350000) { + throw new OptionPowerOutOfRangeError(); + } + return power; + } + + private parseOptionIndex(): number { + const index = this.interaction.options.getNumber("index"); + if (isNil(index)) { + return 1; + } + + if (typeof index !== "number" || isNaN(index)) { + throw new IndexNotANumberError(); } - const index = this.interaction.options.getNumber("index") ?? 1; - if (isNaN(index) || index < 1 || index > 10) { - throw errParseOptions; + if (index < 1 || index > 10 || !Number.isInteger(index)) { + throw new IndexOutOfRangeError(); } + return index; + } + + private async parseOptions(): Promise { + const type = this.parseOptionType(); + const ratio = this.parseOptionCards(); + const power = this.parseOptionPower(); + const index = this.parseOptionIndex(); + return { type, power, @@ -80,19 +112,11 @@ export class UpdateProfile implements Command { }; } + @CatchExecuteError() async executeCommand(): Promise { logger.debug("update profile"); - let options: UpdateProfileOptions; - try { - options = await this.parseOptions(); - } catch (e) { - if (e === errParseOptions) { - logger.warn({ command: this.interaction.toString() }, e.message); - return await this.badRequest(); - } - throw e; - } + const options = await this.parseOptions(); const { type, power, ratio, index } = options; const { user } = this.interaction; logger.debug( diff --git a/src/index.ts b/src/index.ts index fc98bfb..3627769 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { FirebaseDB } from "./store/db"; import { config } from "./config"; import { logger } from "./logger"; -import { Commander } from "./commands/commander"; +import { Commander } from "./commander/commander"; import { Server } from "./server"; import { UserProfileStore } from "./store/user-profiles"; diff --git a/src/server.ts b/src/server.ts index 4398b40..73fb085 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { logger } from "./logger"; import { Client, Intents } from "discord.js"; -import { Commander } from "./commands/commander"; +import { Commander } from "./commander/commander"; import { config } from "./config"; export class Server { diff --git a/test/commands/user/arrange-errors.ts b/test/commands/user/arrange-errors.ts new file mode 100644 index 0000000..892b4ae --- /dev/null +++ b/test/commands/user/arrange-errors.ts @@ -0,0 +1,85 @@ +import { expect } from "chai"; +import { User } from "discord.js"; +import { + EmptyActiveProfilesError, + EmptyProfilesError, + PlayerNotEnoughError, +} from "../../../src/commands/user/arrange-errors"; + +describe("Arrange Command Errors", function () { + describe("PlayerNotEnoughError", function () { + it("should create", function () { + expect(new PlayerNotEnoughError(1)).to.be.instanceOf( + PlayerNotEnoughError + ); + }); + it("should have correct id", function () { + const err = new PlayerNotEnoughError(1); + expect(err.errorId).to.equal("arrange-noEnoughPlayers"); + expect(PlayerNotEnoughError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new PlayerNotEnoughError(1); + expect(err.data).to.deep.equal({ + reason: "no enough players", + count: 1, + }); + }); + }); + + describe("EmptyProfilesError", function () { + it("should create", function () { + expect(new EmptyProfilesError([], false)).to.be.instanceOf( + EmptyProfilesError + ); + }); + it("should have correct id", function () { + const err = new EmptyProfilesError([], false); + expect(err.errorId).to.equal("arrange-emptyProfiles"); + expect(EmptyProfilesError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const users: User[] = [{ id: "user1" } as User, { id: "user2" } as User]; + const err1 = new EmptyProfilesError(users, true); + expect(err1.data).to.deep.equal({ + reason: "empty profile records", + users: [{ id: "user1" } as User, { id: "user2" } as User], + mention: true, + }); + const err2 = new EmptyProfilesError(users, false); + expect(err2.data).to.deep.equal({ + reason: "empty profile records", + users: [{ id: "user1" } as User, { id: "user2" } as User], + mention: false, + }); + }); + }); + + describe("EmptyActiveProfilesError", function () { + it("should create", function () { + expect(new EmptyActiveProfilesError([], false)).to.be.instanceOf( + EmptyActiveProfilesError + ); + }); + it("should have correct id", function () { + const err = new EmptyActiveProfilesError([], false); + expect(err.errorId).to.equal("arrange-emptyActiveProfiles"); + expect(EmptyActiveProfilesError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const users: User[] = [{ id: "user1" } as User, { id: "user2" } as User]; + const err1 = new EmptyActiveProfilesError(users, true); + expect(err1.data).to.deep.equal({ + reason: "empty active profiles", + users: [{ id: "user1" } as User, { id: "user2" } as User], + mention: true, + }); + const err2 = new EmptyActiveProfilesError(users, false); + expect(err2.data).to.deep.equal({ + reason: "empty active profiles", + users: [{ id: "user1" } as User, { id: "user2" } as User], + mention: false, + }); + }); + }); +}); diff --git a/test/commands/user/arrange.ts b/test/commands/user/arrange.ts new file mode 100644 index 0000000..6f49fdf --- /dev/null +++ b/test/commands/user/arrange.ts @@ -0,0 +1,288 @@ +import { expect } from "chai"; +import { User } from "discord.js"; +import { ArrangePlayers } from "../../../src/commands/user/arrange"; +import { + UserProfileRecord, + UserProfileType, +} from "../../../src/models/user-profile"; +import { StubInteraction } from "../../mocks/interaction"; +import { StubUserProfileStore } from "../../mocks/profile-store"; +import { genUserProfileRecord } from "../../mocks/record"; +import { genUser } from "../../mocks/user"; + +describe("Arrange Command", function () { + let users: User[] = []; + let records: UserProfileRecord[] = []; + beforeEach(function () { + users = [ + genUser("user1_id", "user1", "1234"), + genUser("user2_id", "user2", "2345"), + genUser("user3_id", "user3", "3456"), + genUser("user4_id", "user4", "4567"), + genUser("user5_id", "user5", "5678"), + genUser("user6_id", "no-active-profile", "6789"), + genUser("user7_id", "no-active-profile-1", "7890"), + genUser("user8_id", "no-profile", "8901"), + genUser("user9_id", "no-profile-1", "9012"), + ]; + records = [ + genUserProfileRecord({ + // user 1 + 0: { type: UserProfileType.Runner, ratio: 3, power: 150000 }, + }), + genUserProfileRecord({ + // user 2 + 0: { type: UserProfileType.Helper, ratio: 5, power: 200000 }, + }), + genUserProfileRecord({ + // user 3 + 0: { type: UserProfileType.Helper, ratio: 5, power: 150000 }, + }), + genUserProfileRecord({ + // user 4 + 0: { type: UserProfileType.Helper, ratio: 5, power: 150000 }, + }), + genUserProfileRecord({ + // user 5 + 0: { type: UserProfileType.Helper, ratio: 5, power: 150000 }, + }), + genUserProfileRecord( + { + // user 6 + 0: { type: UserProfileType.Helper, ratio: 5, power: 150000 }, + }, + 1 + ), + genUserProfileRecord({ + // user 7 + 1: { type: UserProfileType.Helper, ratio: 5, power: 150000 }, + }), + null, + null, + ]; + }); + + it("should create", function () { + const cmd = new ArrangePlayers(null, null, false); + expect(cmd).to.be.instanceOf(ArrangePlayers); + }); + + it("should reply", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]) + .withGet([users[3].id], records[3]) + .withGet([users[4].id], records[4]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[0]) + .withGetUser(["player2"], users[1]) + .withGetUser(["player3"], users[2]) + .withGetUser(["player4"], users[3]) + .withGetUser(["player5"], users[4]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + true + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: `推薦站位:<@user3_id>,<@user2_id>,<@user1_id>,<@user4_id>,<@user5_id> +\` 1: 幫手 綜合力: 150000 倍率: 5.00\` user3 +\`*2: 幫手 綜合力: 200000 倍率: 5.00\` user2 +\` 3: 跑者 綜合力: 150000 倍率: 3.00\` user1 +\` 4: 幫手 綜合力: 150000 倍率: 5.00\` user4 +\` 5: 幫手 綜合力: 150000 倍率: 5.00\` user5`, + allowedMentions: undefined, + }, + ]); + }); + + it("should reply (no mention)", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]) + .withGet([users[3].id], records[3]) + .withGet([users[4].id], records[4]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[0]) + .withGetUser(["player2"], users[1]) + .withGetUser(["player3"], users[2]) + .withGetUser(["player4"], users[3]) + .withGetUser(["player5"], users[4]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + false + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: `推薦站位:<@user3_id>,<@user2_id>,<@user1_id>,<@user4_id>,<@user5_id> +\` 1: 幫手 綜合力: 150000 倍率: 5.00\` user3 +\`*2: 幫手 綜合力: 200000 倍率: 5.00\` user2 +\` 3: 跑者 綜合力: 150000 倍率: 3.00\` user1 +\` 4: 幫手 綜合力: 150000 倍率: 5.00\` user4 +\` 5: 幫手 綜合力: 150000 倍率: 5.00\` user5`, + allowedMentions: { users: [] }, + }, + ]); + }); + + it("should throw player not enough error", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[0]) + .withGetUser(["player2"], users[1]) + .withGetUser(["player3"], users[2]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + true + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "玩家人數不足四人,不建議開協力 LIVE。", + }, + ]); + }); + + it("should throw empty profile records error", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[7].id], records[7]) + .withGet([users[0].id], records[0]) + .withGet([users[8].id], records[8]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[7]) + .withGetUser(["player2"], users[0]) + .withGetUser(["player3"], users[8]) + .withGetUser(["player4"], users[1]) + .withGetUser(["player5"], users[2]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + true + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "<@user8_id> (no-profile) <@user9_id> (no-profile-1) 沒有設定編組。", + allowedMentions: undefined, + }, + ]); + + it("should throw empty profile records error (no mention)", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[7].id], records[7]) + .withGet([users[0].id], records[0]) + .withGet([users[8].id], records[8]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[7]) + .withGetUser(["player2"], users[0]) + .withGetUser(["player3"], users[8]) + .withGetUser(["player4"], users[1]) + .withGetUser(["player5"], users[2]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + false + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "<@user8_id> (no-profile) <@user9_id> (no-profile-1) 沒有設定編組。", + allowedMentions: [], + }, + ]); + }); + }); + + it("should throw empty active profiles error", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]) + .withGet([users[5].id], records[5]) + .withGet([users[6].id], records[6]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[0]) + .withGetUser(["player2"], users[1]) + .withGetUser(["player3"], users[2]) + .withGetUser(["player4"], users[5]) + .withGetUser(["player5"], users[6]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + true + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "<@user6_id> (no-active-profile) <@user7_id> (no-active-profile-1) 沒有設定使用中編組。", + allowedMentions: undefined, + }, + ]); + + it("should throw empty active profiles error (no mention)", async function () { + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withGet([users[1].id], records[1]) + .withGet([users[2].id], records[2]) + .withGet([users[5].id], records[5]) + .withGet([users[6].id], records[6]); + + const stubInteraction = new StubInteraction() + .withGetUser(["player1"], users[0]) + .withGetUser(["player2"], users[1]) + .withGetUser(["player3"], users[2]) + .withGetUser(["player4"], users[5]) + .withGetUser(["player5"], users[6]); + + const cmd = new ArrangePlayers( + stubInteraction.build(), + stubProfileStore.build(), + false + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "<@user6_id> (no-active-profile) <@user7_id> (no-active-profile-1) 沒有設定使用中編組。", + allowedMentions: [], + }, + ]); + }); + }); +}); diff --git a/test/commands/user/profile/activate-errors.ts b/test/commands/user/profile/activate-errors.ts new file mode 100644 index 0000000..56ca42a --- /dev/null +++ b/test/commands/user/profile/activate-errors.ts @@ -0,0 +1,95 @@ +import { expect } from "chai"; +import { + EmptyIndexError, + EmptyProfileError, + IndexNotANumberError, + IndexOutOfRangeError, + NoProfileRecordError, +} from "../../../../src/commands/user/profile/activate-errors"; + +describe("Profile Activate Command Errors", function () { + describe("EmptyIndexError", function () { + it("should create", function () { + expect(new EmptyIndexError()).to.be.instanceOf(EmptyIndexError); + }); + it("should have correct id", function () { + const err = new EmptyIndexError(); + expect(err.errorId).to.equal("profileActivate-emptyIndex"); + expect(EmptyIndexError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new EmptyIndexError(); + expect(err.data).to.deep.equal({ + reason: "index is empty", + }); + }); + }); + + describe("IndexNotANumberError", function () { + it("should create", function () { + expect(new IndexNotANumberError()).to.be.instanceOf(IndexNotANumberError); + }); + it("should have correct id", function () { + const err = new IndexNotANumberError(); + expect(err.errorId).to.equal("profileActivate-indexNotANumber"); + expect(IndexNotANumberError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new IndexNotANumberError(); + expect(err.data).to.deep.equal({ + reason: "index is not a number", + }); + }); + }); + + describe("IndexOutOfRangeError", function () { + it("should create", function () { + expect(new IndexOutOfRangeError()).to.be.instanceOf(IndexOutOfRangeError); + }); + it("should have correct id", function () { + const err = new IndexOutOfRangeError(); + expect(err.errorId).to.equal("profileActivate-indexOutOfRange"); + expect(IndexOutOfRangeError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new IndexOutOfRangeError(); + expect(err.data).to.deep.equal({ + reason: "index out of range", + }); + }); + }); + + describe("NoProfileRecordError", function () { + it("should create", function () { + expect(new NoProfileRecordError()).to.be.instanceOf(NoProfileRecordError); + }); + it("should have correct id", function () { + const err = new NoProfileRecordError(); + expect(err.errorId).to.equal("profileActivate-noProfileRecord"); + expect(NoProfileRecordError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new NoProfileRecordError(); + expect(err.data).to.deep.equal({ + reason: "user does not have profile record", + }); + }); + }); + + describe("EmptyProfileError", function () { + it("should create", function () { + expect(new EmptyProfileError()).to.be.instanceOf(EmptyProfileError); + }); + it("should have correct id", function () { + const err = new EmptyProfileError(); + expect(err.errorId).to.equal("profileActivate-emptyProfile"); + expect(EmptyProfileError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new EmptyProfileError(); + expect(err.data).to.deep.equal({ + reason: "empty profile selected", + }); + }); + }); +}); diff --git a/test/commands/user/profile/activate.ts b/test/commands/user/profile/activate.ts new file mode 100644 index 0000000..7df8cfe --- /dev/null +++ b/test/commands/user/profile/activate.ts @@ -0,0 +1,225 @@ +import { expect } from "chai"; +import { User } from "discord.js"; +import { ActivateProfile } from "../../../../src/commands/user/profile/activate"; +import { + UserProfileRecord, + UserProfileType, +} from "../../../../src/models/user-profile"; +import { StubInteraction } from "../../../mocks/interaction"; +import { StubUserProfileStore } from "../../../mocks/profile-store"; +import { genUserProfileRecord } from "../../../mocks/record"; +import { genUser } from "../../../mocks/user"; +import { match } from "sinon"; + +describe("Profile Activate Command", function () { + let users: User[] = []; + let records: UserProfileRecord[] = []; + beforeEach(function () { + users = [ + genUser("user1_id", "user1", "1234"), + genUser("user2_id", "no-profile", "2345"), + ]; + records = [ + genUserProfileRecord({ + 0: { type: UserProfileType.Runner, power: 250000, ratio: 3 }, + 1: { type: UserProfileType.Helper, power: 200000, ratio: 5 }, + }), + null, + ]; + }); + + it("should create", function () { + const cmd = new ActivateProfile(null, null); + expect(cmd).to.be.instanceOf(ActivateProfile); + }); + + it("should set activate profile and reply", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetNumber(["index"], 2); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withSet([users[0].id, match.any], undefined); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + "已更新使用中編組編號。使用中編組:\n```\n2: 幫手 綜合力: 200000 倍率: 5.00\n```", + ]); + + const updatedRecord: UserProfileRecord = { ...records[0], active: 1 }; + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[0].id, + updatedRecord, + ]); + }); + + it("should throw empty index error", async function () { + const stubInteraction = new StubInteraction().withGetNumber( + ["index"], + undefined + ); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "沒有輸入編號 (index)。", + }, + ]); + }); + + it("should throw index not a number error (NaN)", async function () { + const stubInteraction = new StubInteraction().withGetNumber(["index"], NaN); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。", + }, + ]); + }); + + it("should throw index not a number error (string)", async function () { + const stubInteraction = new StubInteraction().withGetNumber( + ["index"], + "NotANumber" as unknown as number + ); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。", + }, + ]); + }); + + it("should throw index out of range error (< 1)", async function () { + const stubInteraction = new StubInteraction().withGetNumber(["index"], 0); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw index out of range error (> 10)", async function () { + const stubInteraction = new StubInteraction().withGetNumber(["index"], 11); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw index out of range error (floating point)", async function () { + const stubInteraction = new StubInteraction().withGetNumber( + ["index"], + 1.25 + ); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw no profile record error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[1]) + .withGetNumber(["index"], 2); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[1].id], + records[1] + ); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }, + ]); + }); + + it("should throw empty profile error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetNumber(["index"], 10); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[0].id], + records[0] + ); + + const cmd = new ActivateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "選擇的編組是空白的。", + }, + ]); + }); +}); diff --git a/test/commands/user/profile/list-errors.ts b/test/commands/user/profile/list-errors.ts new file mode 100644 index 0000000..5cc5688 --- /dev/null +++ b/test/commands/user/profile/list-errors.ts @@ -0,0 +1,41 @@ +import { expect } from "chai"; +import { + NoProfileRecordError, + NoValidProfileError, +} from "../../../../src/commands/user/profile/list-errors"; + +describe("Profile List Command Errors", function () { + describe("NoProfileRecordError", function () { + it("should create", function () { + expect(new NoProfileRecordError()).to.be.instanceOf(NoProfileRecordError); + }); + it("should have correct id", function () { + const err = new NoProfileRecordError(); + expect(err.errorId).to.equal("profileList-noProfileRecord"); + expect(NoProfileRecordError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new NoProfileRecordError(); + expect(err.data).to.deep.equal({ + reason: "user does not have profile record", + }); + }); + }); + + describe("NoValidProfileError", function () { + it("should create", function () { + expect(new NoValidProfileError()).to.be.instanceOf(NoValidProfileError); + }); + it("should have correct id", function () { + const err = new NoValidProfileError(); + expect(err.errorId).to.equal("profileList-noValidProfile"); + expect(NoValidProfileError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new NoValidProfileError(); + expect(err.data).to.deep.equal({ + reason: "user does not have any valid profile", + }); + }); + }); +}); diff --git a/test/commands/user/profile/list.ts b/test/commands/user/profile/list.ts new file mode 100644 index 0000000..4ffe717 --- /dev/null +++ b/test/commands/user/profile/list.ts @@ -0,0 +1,152 @@ +import { expect } from "chai"; +import { User } from "discord.js"; +import { ListProfile } from "../../../../src/commands/user/profile/list"; +import { + UserProfileRecord, + UserProfileType, +} from "../../../../src/models/user-profile"; +import { StubInteraction } from "../../../mocks/interaction"; +import { StubUserProfileStore } from "../../../mocks/profile-store"; +import { genUserProfileRecord } from "../../../mocks/record"; +import { genUser } from "../../../mocks/user"; + +describe("Profile List Command", function () { + let users: User[] = []; + let records: UserProfileRecord[] = []; + beforeEach(function () { + users = [ + genUser("user1_id", "user1", "1234"), + genUser("user2_id", "user2", "2345"), + genUser("user3_id", "no-profile-record", "3456"), + genUser("user4_id", "no-valid-profile", "4567"), + ]; + records = [ + genUserProfileRecord({ + 0: { type: UserProfileType.Runner, power: 250000, ratio: 3 }, + 1: { type: UserProfileType.Helper, power: 200000, ratio: 5 }, + }), + genUserProfileRecord( + { + 0: { type: UserProfileType.Helper, power: 250000, ratio: 5.5 }, + 1: { type: UserProfileType.Helper, power: 260000, ratio: 5.3 }, + }, + 1 + ), + null, + genUserProfileRecord({}), + ]; + }); + + it("should create", function () { + const cmd = new ListProfile(null, null); + expect(cmd).to.be.instanceOf(ListProfile); + }); + + it("should list profiles and reply (same user)", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetUser(["user"], undefined); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[0].id], + records[0] + ); + + const cmd = new ListProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: `user1 (<@user1_id>) 的編組資料: +\`\`\` +使用中的編組: *1 + *1: 跑者 綜合力: 250000 倍率: 3.00 + 2: 幫手 綜合力: 200000 倍率: 5.00 +\`\`\``, + allowedMentions: { + users: [], + }, + }, + ]); + }); + + it("should list profiles and reply (other user)", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetUser(["user"], users[1]); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[1].id], + records[1] + ); + + const cmd = new ListProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: `user2 (<@user2_id>) 的編組資料: +\`\`\` +使用中的編組: *2 + 1: 幫手 綜合力: 250000 倍率: 5.50 + *2: 幫手 綜合力: 260000 倍率: 5.30 +\`\`\``, + allowedMentions: { + users: [], + }, + }, + ]); + }); + + it("should throw no profile record error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[2]) + .withGetUser(["user"], undefined); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[2].id], + records[2] + ); + + const cmd = new ListProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }, + ]); + }); + + it("should throw no valid profile error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[3]) + .withGetUser(["user"], undefined); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[3].id], + records[3] + ); + + const cmd = new ListProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }, + ]); + }); +}); diff --git a/test/commands/user/profile/remove-errors.ts b/test/commands/user/profile/remove-errors.ts new file mode 100644 index 0000000..aaf38b9 --- /dev/null +++ b/test/commands/user/profile/remove-errors.ts @@ -0,0 +1,95 @@ +import { expect } from "chai"; +import { + EmptyIndexError, + EmptyProfileError, + IndexNotANumberError, + IndexOutOfRangeError, + NoProfileRecordError, +} from "../../../../src/commands/user/profile/remove-errors"; + +describe("Profile Remove Command Errors", function () { + describe("EmptyIndexError", function () { + it("should create", function () { + expect(new EmptyIndexError()).to.be.instanceOf(EmptyIndexError); + }); + it("should have correct id", function () { + const err = new EmptyIndexError(); + expect(err.errorId).to.equal("profileRemove-emptyIndex"); + expect(EmptyIndexError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new EmptyIndexError(); + expect(err.data).to.deep.equal({ + reason: "index is empty", + }); + }); + }); + + describe("IndexNotANumberError", function () { + it("should create", function () { + expect(new IndexNotANumberError()).to.be.instanceOf(IndexNotANumberError); + }); + it("should have correct id", function () { + const err = new IndexNotANumberError(); + expect(err.errorId).to.equal("profileRemove-indexNotANumber"); + expect(IndexNotANumberError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new IndexNotANumberError(); + expect(err.data).to.deep.equal({ + reason: "index is not a number", + }); + }); + }); + + describe("IndexOutOfRangeError", function () { + it("should create", function () { + expect(new IndexOutOfRangeError()).to.be.instanceOf(IndexOutOfRangeError); + }); + it("should have correct id", function () { + const err = new IndexOutOfRangeError(); + expect(err.errorId).to.equal("profileRemove-indexOutOfRange"); + expect(IndexOutOfRangeError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new IndexOutOfRangeError(); + expect(err.data).to.deep.equal({ + reason: "index out of range", + }); + }); + }); + + describe("NoProfileRecordError", function () { + it("should create", function () { + expect(new NoProfileRecordError()).to.be.instanceOf(NoProfileRecordError); + }); + it("should have correct id", function () { + const err = new NoProfileRecordError(); + expect(err.errorId).to.equal("profileRemove-noProfileRecord"); + expect(NoProfileRecordError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new NoProfileRecordError(); + expect(err.data).to.deep.equal({ + reason: "user does not have profile record", + }); + }); + }); + + describe("EmptyProfileError", function () { + it("should create", function () { + expect(new EmptyProfileError()).to.be.instanceOf(EmptyProfileError); + }); + it("should have correct id", function () { + const err = new EmptyProfileError(); + expect(err.errorId).to.equal("profileRemove-emptyProfile"); + expect(EmptyProfileError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new EmptyProfileError(); + expect(err.data).to.deep.equal({ + reason: "empty profile selected", + }); + }); + }); +}); diff --git a/test/commands/user/profile/remove.ts b/test/commands/user/profile/remove.ts new file mode 100644 index 0000000..3ce8e28 --- /dev/null +++ b/test/commands/user/profile/remove.ts @@ -0,0 +1,261 @@ +import { expect } from "chai"; +import { User } from "discord.js"; +import { match } from "sinon"; +import { RemoveProfile } from "../../../../src/commands/user/profile/remove"; +import { + UserProfileRecord, + UserProfileType, +} from "../../../../src/models/user-profile"; +import { StubInteraction } from "../../../mocks/interaction"; +import { StubUserProfileStore } from "../../../mocks/profile-store"; +import { genUserProfileRecord } from "../../../mocks/record"; +import { genUser } from "../../../mocks/user"; + +describe("Profile Remove Command", function () { + let users: User[] = []; + let records: UserProfileRecord[]; + beforeEach(function () { + users = [ + genUser("user1_id", "user1", "1234"), + genUser("user2_id", "no-profile", "2345"), + ]; + records = [ + genUserProfileRecord({ + 0: { type: UserProfileType.Runner, power: 250000, ratio: 3 }, + 1: { type: UserProfileType.Helper, power: 200000, ratio: 5 }, + }), + null, + ]; + }); + + it("should create", function () { + const cmd = new RemoveProfile(null, null); + expect(cmd).to.be.instanceOf(RemoveProfile); + }); + + it("should remove profile and reply", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetNumber(["index"], 2); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withSet([users[0].id, match.any], undefined); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + `已移除選擇的編組。你的編組資料: +\`\`\` +使用中的編組: *1 + *1: 跑者 綜合力: 250000 倍率: 3.00 +\`\`\``, + ]); + + const updatedRecord: UserProfileRecord = genUserProfileRecord({ + 0: { type: UserProfileType.Runner, power: 250000, ratio: 3 }, + }); + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[0].id, + updatedRecord, + ]); + }); + + it("should not change active profile when removing", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetNumber(["index"], 1); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withSet([users[0].id, match.any], undefined); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + + const updatedRecord: UserProfileRecord = genUserProfileRecord( + { + 1: { type: UserProfileType.Helper, power: 200000, ratio: 5 }, + }, + 0 + ); + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[0].id, + updatedRecord, + ]); + }); + + it("should throw empty index error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[0].id], + records[0] + ); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "沒有輸入編號 (index)。", + }, + ]); + }); + + it("should throw index not a number error (NaN)", async function () { + const stubInteraction = new StubInteraction().withGetNumber(["index"], NaN); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。", + }, + ]); + }); + + it("should throw index not a number error (string)", async function () { + const stubInteraction = new StubInteraction().withGetNumber( + ["index"], + "NotANumber" as unknown as number + ); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。", + }, + ]); + }); + + it("should throw index out of range error (< 1)", async function () { + const stubInteraction = new StubInteraction().withGetNumber(["index"], 0); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw index out of range error (> 10)", async function () { + const stubInteraction = new StubInteraction().withGetNumber(["index"], 11); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw index out of range error (floating point)", async function () { + const stubInteraction = new StubInteraction().withGetNumber( + ["index"], + 1.25 + ); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw no profile record error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[1]) + .withGetNumber(["index"], 2); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[1].id], + records[1] + ); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "沒有編組資料。請先使用 /profile update 指令新增編組。", + }, + ]); + }); + + it("should throw empty profile error", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetNumber(["index"], 10); + + const stubProfileStore = new StubUserProfileStore().withGet( + [users[0].id], + records[0] + ); + + const cmd = new RemoveProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "選擇的編組是空白的。", + }, + ]); + }); +}); diff --git a/test/commands/user/profile/update-errors.ts b/test/commands/user/profile/update-errors.ts new file mode 100644 index 0000000..a93995a --- /dev/null +++ b/test/commands/user/profile/update-errors.ts @@ -0,0 +1,121 @@ +import { expect } from "chai"; +import { + IndexNotANumberError, + IndexOutOfRangeError, + InvalidOptionPowerError, + InvalidOptionTypeError, + OptionPowerOutOfRangeError, + InvalidOptionCardsError, +} from "../../../../src/commands/user/profile/update-errors"; + +describe("Profile Update Command Errors", function () { + describe("IndexNotANumberError", function () { + it("should create", function () { + expect(new IndexNotANumberError()).to.be.instanceOf(IndexNotANumberError); + }); + it("should have correct id", function () { + const err = new IndexNotANumberError(); + expect(err.errorId).to.equal("profileUpdate-indexNotANumber"); + expect(IndexNotANumberError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new IndexNotANumberError(); + expect(err.data).to.deep.equal({ + reason: "index is not a number", + }); + }); + }); + + describe("IndexOutOfRangeError", function () { + it("should create", function () { + expect(new IndexOutOfRangeError()).to.be.instanceOf(IndexOutOfRangeError); + }); + it("should have correct id", function () { + const err = new IndexOutOfRangeError(); + expect(err.errorId).to.equal("profileUpdate-indexOutOfRange"); + expect(IndexOutOfRangeError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new IndexOutOfRangeError(); + expect(err.data).to.deep.equal({ + reason: "index out of range", + }); + }); + }); + + describe("InvalidOptionTypeError", function () { + it("should create", function () { + expect(new InvalidOptionTypeError()).to.be.instanceOf( + InvalidOptionTypeError + ); + }); + it("should have correct id", function () { + const err = new InvalidOptionTypeError(); + expect(err.errorId).to.equal("profileUpdate-invalidOptionType"); + expect(InvalidOptionTypeError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new InvalidOptionTypeError(); + expect(err.data).to.deep.equal({ + reason: "option type is invalid", + }); + }); + }); + + describe("InvalidOptionPowerError", function () { + it("should create", function () { + expect(new InvalidOptionPowerError()).to.be.instanceOf( + InvalidOptionPowerError + ); + }); + it("should have correct id", function () { + const err = new InvalidOptionPowerError(); + expect(err.errorId).to.equal("profileUpdate-invalidOptionPower"); + expect(InvalidOptionPowerError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new InvalidOptionPowerError(); + expect(err.data).to.deep.equal({ + reason: "option power is invalid", + }); + }); + }); + + describe("OptionPowerOutOfRangeError", function () { + it("should create", function () { + expect(new OptionPowerOutOfRangeError()).to.be.instanceOf( + OptionPowerOutOfRangeError + ); + }); + it("should have correct id", function () { + const err = new OptionPowerOutOfRangeError(); + expect(err.errorId).to.equal("profileUpdate-optionPowerOutOfRange"); + expect(OptionPowerOutOfRangeError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new OptionPowerOutOfRangeError(); + expect(err.data).to.deep.equal({ + reason: "option power out of range", + }); + }); + }); + + describe("InvalidOptionCardsError", function () { + it("should create", function () { + expect(new InvalidOptionCardsError()).to.be.instanceOf( + InvalidOptionCardsError + ); + }); + it("should have correct id", function () { + const err = new InvalidOptionCardsError(); + expect(err.errorId).to.equal("profileUpdate-invalidOptionCards"); + expect(InvalidOptionCardsError.id).to.equal(err.errorId); + }); + it("should have correct data", function () { + const err = new InvalidOptionCardsError(); + expect(err.data).to.deep.equal({ + reason: "option cards is invalid", + }); + }); + }); +}); diff --git a/test/commands/user/profile/update.ts b/test/commands/user/profile/update.ts new file mode 100644 index 0000000..0b9c912 --- /dev/null +++ b/test/commands/user/profile/update.ts @@ -0,0 +1,603 @@ +import { expect } from "chai"; +import { User } from "discord.js"; +import { merge, range } from "lodash"; +import { match, stub } from "sinon"; +import { UpdateProfile } from "../../../../src/commands/user/profile/update"; +import { logger } from "../../../../src/logger"; +import { + UserProfileRecord, + UserProfileType, +} from "../../../../src/models/user-profile"; +import { StubInteraction } from "../../../mocks/interaction"; +import { StubUserProfileStore } from "../../../mocks/profile-store"; +import { genUserProfileRecord } from "../../../mocks/record"; +import { genUser } from "../../../mocks/user"; + +describe("Profile Update Command", function () { + let users: User[] = []; + let records: UserProfileRecord[] = []; + beforeEach(function () { + users = [ + genUser("user1_id", "user1", "1234"), + genUser("user2_id", "no-profile", "2345"), + ]; + records = [ + genUserProfileRecord({ + 0: { type: UserProfileType.Runner, power: 250000, ratio: 3 }, + }), + null, + ]; + }); + + it("should create", function () { + const cmd = new UpdateProfile(null, null); + expect(cmd).to.be.instanceOf(UpdateProfile); + }); + + it("should update profile and reply (no record, default index)", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[1]) + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[1].id], records[1]) + .withSet([users[1].id, match.any], null); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.be.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + `已更新。你的編組資料: +\`\`\` +使用中的編組: *1 + *1: 跑者 綜合力: 250000 倍率: 4.16 +\`\`\``, + ]); + + // FIXME: Chai doesn't support tolerance of number comparison in deep + // equality assertion, yet? + const updatedRecord = genUserProfileRecord({ + 0: { + type: UserProfileType.Runner, + power: 250000, + ratio: 4.1595187199999994, + }, + }); + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[1].id, + updatedRecord, + ]); + }); + + it("should update profile and reply (has record, default index)", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withSet([users[0].id, match.any], null); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.be.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + `已更新。你的編組資料: +\`\`\` +使用中的編組: *1 + *1: 跑者 綜合力: 250000 倍率: 4.16 +\`\`\``, + ]); + + // FIXME: Chai doesn't support tolerance of number comparison in deep + // equality assertion, yet? + const updatedRecord = genUserProfileRecord({ + 0: { + type: UserProfileType.Runner, + power: 250000, + ratio: 4.1595187199999994, + }, + }); + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[0].id, + updatedRecord, + ]); + }); + + it("should update profile and reply (no record, given index)", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[1]) + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], 2); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[1].id], records[1]) + .withSet([users[1].id, match.any], null); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.be.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + `已更新。你的編組資料: +\`\`\` +使用中的編組: *1 + 2: 跑者 綜合力: 250000 倍率: 4.16 +\`\`\``, + ]); + + // FIXME: Chai doesn't support tolerance of number comparison in deep + // equality assertion, yet? + const updatedRecord = genUserProfileRecord({ + 1: { + type: UserProfileType.Runner, + power: 250000, + ratio: 4.1595187199999994, + }, + }); + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[1].id, + updatedRecord, + ]); + }); + + it("should update profile and reply (has record, given index)", async function () { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], 2); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withSet([users[0].id, match.any], null); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.be.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + `已更新。你的編組資料: +\`\`\` +使用中的編組: *1 + *1: 跑者 綜合力: 250000 倍率: 3.00 + 2: 跑者 綜合力: 250000 倍率: 4.16 +\`\`\``, + ]); + + // FIXME: Chai doesn't support tolerance of number comparison in deep + // equality assertion, yet? + const updatedRecord = records[0]; + updatedRecord.profiles[1] = { + type: UserProfileType.Runner, + power: 250000, + ratio: 4.1595187199999994, + }; + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[0].id, + updatedRecord, + ]); + }); + + it("should be able to update up to 10 profiles", async function () { + for (const i of range(1, 11)) { + const stubInteraction = new StubInteraction() + .withUser(users[0]) + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], i); + + const stubProfileStore = new StubUserProfileStore() + .withGet([users[0].id], records[0]) + .withSet([users[0].id, match.any], null); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + + // FIXME: Chai doesn't support tolerance of number comparison in deep + // equality assertion, yet? + const updatedRecord = { ...records[0] }; + updatedRecord.profiles = merge(updatedRecord.profiles, { + [i - 1]: { + type: UserProfileType.Runner, + power: 250000, + ratio: 4.1595187199999994, + }, + }); + expect(stubProfileStore.fakeSet.callCount).to.equal(1); + expect(stubProfileStore.fakeSet.args[0]).to.deep.equal([ + users[0].id, + updatedRecord, + ]); + } + }); + + it("should throw index not a number error (NaN)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], NaN); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.be.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。", + }, + ]); + }); + + it("should throw index not a number error (string)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], "NotANumber" as unknown as number); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.be.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。", + }, + ]); + }); + + it("should throw index out of range error (< 1)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], 0); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw index out of range error (> 10)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], 11); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw index out of range error (floating point)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], 1.25); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編號 (index) 錯誤。僅能輸入 1~10。", + }, + ]); + }); + + it("should throw invalid option type error (not string)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], 1 as unknown as string) + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編組類型 (type) 錯誤。僅能選擇跑者或幫手其中一種。", + }, + ]); + }); + + it("should throw invalid option type error (unknown type)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "unknown") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + + // silence the unknown type error log message + const stubLogger = stub(logger, "error"); + expect(await cmd.executeCommand()).to.not.exist; + stubLogger.restore(); + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的編組類型 (type) 錯誤。僅能選擇跑者或幫手其中一種。", + }, + ]); + }); + + it("should throw invalid option power error (string)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], "NotANumber" as unknown as number) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的綜合力 (power) 格式錯誤。", + }, + ]); + }); + + it("should throw invalid option power error (NaN)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], NaN) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: "輸入的綜合力 (power) 格式錯誤。", + }, + ]); + }); + + it("should throw option power out of range error (< 10000)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 9999) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的綜合力 (power) 過低或過高。請輸入完整數字,而不是以萬為單位的簡寫。", + }, + ]); + }); + + it("should throw option power out of range error (> 350000)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 350001) + .withGetString(["cards"], "130,100,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的綜合力 (power) 過低或過高。請輸入完整數字,而不是以萬為單位的簡寫。", + }, + ]); + }); + + it("should throw invalid option cards error (not a string)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], 130 as unknown as string) + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的卡片倍率 (cards) 格式錯誤。請以逗號 , 分隔,不要使用空白分隔。", + }, + ]); + }); + it("should throw invalid option cards error (less than 4 cards)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的卡片倍率 (cards) 格式錯誤。請以逗號 , 分隔,不要使用空白分隔。", + }, + ]); + }); + + it("should throw invalid option cards error (more than 5 cards)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,100,80,80,60,40") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的卡片倍率 (cards) 格式錯誤。請以逗號 , 分隔,不要使用空白分隔。", + }, + ]); + }); + + it("should throw invalid option cards error (bad separator)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130 100 80 80 60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的卡片倍率 (cards) 格式錯誤。請以逗號 , 分隔,不要使用空白分隔。", + }, + ]); + }); + + it("should throw invalid option cards error (not a number)", async function () { + const stubInteraction = new StubInteraction() + .withGetString(["type"], "r") + .withGetNumber(["power"], 250000) + .withGetString(["cards"], "130,aaa,80,80,60") + .withGetNumber(["index"], undefined); + + const stubProfileStore = new StubUserProfileStore(); + + const cmd = new UpdateProfile( + stubInteraction.build(), + stubProfileStore.build() + ); + expect(await cmd.executeCommand()).to.not.exist; + expect(stubInteraction.fakeReply.callCount).to.equal(1); + expect(stubInteraction.fakeReply.args[0]).to.deep.equal([ + { + content: + "輸入的卡片倍率 (cards) 格式錯誤。請以逗號 , 分隔,不要使用空白分隔。", + }, + ]); + }); +}); diff --git a/test/mocks/interaction.ts b/test/mocks/interaction.ts new file mode 100644 index 0000000..3645993 --- /dev/null +++ b/test/mocks/interaction.ts @@ -0,0 +1,69 @@ +import { CommandInteraction, User } from "discord.js"; +import { fake, SinonStub, stub } from "sinon"; +import { genUser } from "./user"; + +type Options = typeof CommandInteraction.prototype.options; +type GetUser = typeof CommandInteraction.prototype.options.getUser; +type GetString = typeof CommandInteraction.prototype.options.getString; +type GetNumber = typeof CommandInteraction.prototype.options.getNumber; +type Reply = typeof CommandInteraction.prototype.reply; + +export class StubInteraction { + user: User = genUser("issueCommandUser", "command-user", "0000"); + fakeGetUser: SinonStub, ReturnType>; + fakeGetString: SinonStub, ReturnType>; + fakeGetNumber: SinonStub, ReturnType>; + fakeReply = fake.resolves, ReturnType>(null); + + withUser(user: User): StubInteraction { + this.user = user; + return this; + } + + withGetUser( + args: Parameters, + returns: Parameters[0] + ): StubInteraction { + if (!this.fakeGetUser) { + this.fakeGetUser = stub(); + } + this.fakeGetUser.withArgs(...args).returns(returns); + return this; + } + + withGetNumber( + args: Parameters, + returns: Parameters[0] + ): StubInteraction { + if (!this.fakeGetNumber) { + this.fakeGetNumber = stub(); + } + this.fakeGetNumber.withArgs(...args).returns(returns); + return this; + } + + withGetString( + args: Parameters, + returns: Parameters[0] + ): StubInteraction { + if (!this.fakeGetString) { + this.fakeGetString = stub(); + } + this.fakeGetString.withArgs(...args).returns(returns); + return this; + } + + build(): CommandInteraction { + const options: Partial = { + getUser: this.fakeGetUser, + getNumber: this.fakeGetNumber, + getString: this.fakeGetString, + }; + + return (, "valueOf">>{ + user: this.user, + options, + reply: this.fakeReply as unknown, + }) as CommandInteraction; + } +} diff --git a/test/mocks/profile-store.ts b/test/mocks/profile-store.ts new file mode 100644 index 0000000..a8c8876 --- /dev/null +++ b/test/mocks/profile-store.ts @@ -0,0 +1,42 @@ +import { SinonStub, SinonSpy, fake, stub } from "sinon"; +import { UserProfileStore } from "../../src/store/user-profiles"; + +type Init = typeof UserProfileStore.prototype.init; +type Get = typeof UserProfileStore.prototype.get; +type Set = typeof UserProfileStore.prototype.set; + +export class StubUserProfileStore { + fakeInit: SinonSpy, ReturnType> = fake.resolves(null); + fakeGet: SinonStub, ReturnType>; + fakeSet: SinonStub, ReturnType>; + + withGet( + args: Parameters, + resolves: Parameters[0] + ): StubUserProfileStore { + if (!this.fakeGet) { + this.fakeGet = stub(); + } + this.fakeGet.withArgs(...args).resolves(resolves); + return this; + } + + withSet( + args: Parameters, + resolves: Parameters[0] + ): StubUserProfileStore { + if (!this.fakeSet) { + this.fakeSet = stub(); + } + this.fakeSet.withArgs(...args).resolves(resolves); + return this; + } + + build(): UserProfileStore { + return (>{ + init: this.fakeInit, + get: this.fakeGet, + set: this.fakeSet, + }) as UserProfileStore; + } +} diff --git a/test/mocks/record.ts b/test/mocks/record.ts new file mode 100644 index 0000000..ef556d5 --- /dev/null +++ b/test/mocks/record.ts @@ -0,0 +1,23 @@ +import { UserProfile } from "../../src/models/user-profile"; + +type ProfileKey = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; +type ProfileCollection = { [k in ProfileKey]?: UserProfile }; + +export function genUserProfileRecord( + profileCollection: ProfileCollection, + active = 0 +) { + const profiles = Array(10) + .fill(null) + .map((e, i) => { + const iStr = i.toString() as ProfileKey; + if (Object.prototype.hasOwnProperty.call(profileCollection, iStr)) { + return profileCollection[iStr]; + } + return e; + }); + return { + profiles, + active, + }; +} diff --git a/test/mocks/user.ts b/test/mocks/user.ts new file mode 100644 index 0000000..000a761 --- /dev/null +++ b/test/mocks/user.ts @@ -0,0 +1,19 @@ +import { User } from "discord.js"; + +export function genUser( + id: string, + username: string, + discriminator: string +): User { + return (>{ + id, + username, + discriminator, + get tag(): string { + return `${this.username}#${this.discriminator}`; + }, + toString(): string { + return `<@${this.id}>`; + }, + }) as User; +} diff --git a/tsconfig.json b/tsconfig.json index f3a20f2..2658a0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "experimentalDecorators": true, "module": "commonjs", "outDir": "./build", "noImplicitAny": true,