From ff3f9f2e9f1f49299cdb152df3b7302c4d390e64 Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Thu, 26 Dec 2024 13:45:56 -0300 Subject: [PATCH 01/10] feat: install super expressive --- package.json | 3 +++ src/deps.deno.ts | 2 ++ src/deps.node.ts | 2 ++ src/utils/array.ts | 20 ++++++++++++++++---- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index d492021..6d01f5e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "peerDependencies": { "grammy": "^1.17.1" }, + "dependencies": { + "super-expressive": "^2.0.0" + }, "devDependencies": { "deno2node": "^1.14.0", "typescript": "^5.6.3" diff --git a/src/deps.deno.ts b/src/deps.deno.ts index e344dbd..f30e2eb 100644 --- a/src/deps.deno.ts +++ b/src/deps.deno.ts @@ -22,5 +22,7 @@ export type { LanguageCode, MessageEntity, } from "https://lib.deno.dev/x/grammy@1/types.ts"; +import SuperExpressive from "npm:super-expressive"; +export { SuperExpressive }; // TODO: bring this back once the types are available on the "web" runtimes // export { LanguageCodes } from "https://lib.deno.dev/x/grammy@1/types.ts"; diff --git a/src/deps.node.ts b/src/deps.node.ts index cb831b7..93f150c 100644 --- a/src/deps.node.ts +++ b/src/deps.node.ts @@ -22,5 +22,7 @@ export type { LanguageCode, MessageEntity } from "grammy/types"; +import SuperExpressive from "npm:super-expressive"; +export { SuperExpressive }; // TODO: bring this back once the types are available on the "web" runtimes // export { LanguageCodes } from "grammy/types"; \ No newline at end of file diff --git a/src/utils/array.ts b/src/utils/array.ts index e085f61..371163a 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -1,3 +1,5 @@ +import { SuperExpressive } from "../deps.deno.ts"; + export type MaybeArray = T | T[]; export const ensureArray = (value: MaybeArray): T[] => Array.isArray(value) ? value : [value]; @@ -14,8 +16,18 @@ export function escapeSpecial(str: string) { ); } export function getCommandsRegex(prefix: string) { - return new RegExp( - `(\?\<\!\\S)(\?${escapeSpecial(prefix)})\\S+(\\s|$)`, - "g", - ); + return SuperExpressive() + .assertNotBehind + .nonWhitespaceChar + .end() + .namedCapture("prefix") + .string(`${prefix}`) + .end() + .oneOrMore + .nonWhitespaceChar + .anyOf + .whitespaceChar + .endOfInput + .end() + .toRegex(); } From 07981a6cee801272007177627b9c9d3f6469292f Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 16:33:21 -0300 Subject: [PATCH 02/10] refactor: remove unused code --- src/utils/array.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/utils/array.ts b/src/utils/array.ts index 371163a..add2c87 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -4,17 +4,6 @@ export type MaybeArray = T | T[]; export const ensureArray = (value: MaybeArray): T[] => Array.isArray(value) ? value : [value]; -const specialChars = "\\.^$|?*+()[]{}-".split(""); - -const replaceAll = (s: string, find: string, replace: string) => - s.replace(new RegExp(`\\${find}`, "g"), replace); - -export function escapeSpecial(str: string) { - return specialChars.reduce( - (acc, char) => replaceAll(acc, char, `\\${char}`), - str, - ); -} export function getCommandsRegex(prefix: string) { return SuperExpressive() .assertNotBehind From d345425993ab74dc9cdcf2d945ee6018ec649921 Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 16:52:16 -0300 Subject: [PATCH 03/10] refactor: regexes into super expressive --- src/command.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/command.ts b/src/command.ts index 19dee50..7f05a05 100644 --- a/src/command.ts +++ b/src/command.ts @@ -12,6 +12,7 @@ import { type LanguageCode, type Middleware, type MiddlewareObj, + SuperExpressive, } from "./deps.deno.ts"; import type { CommandOptions } from "./types.ts"; import { ensureArray, type MaybeArray } from "./utils/array.ts"; @@ -47,7 +48,17 @@ export interface CommandMatch { match?: RegExpExecArray | null; } -const NOCASE_COMMAND_NAME_REGEX = /^[0-9a-z_]+$/i; +const NOCASE_COMMAND_NAME_REGEX = SuperExpressive() + .caseInsensitive + .oneOrMore + .startOfInput + .anyOf + .range("0", "9") + .range("a", "z") + .char("_") + .end() + .endOfInput + .toRegex(); /** * Class that represents a single command and allows you to configure it. @@ -197,7 +208,18 @@ export class Command implements MiddlewareObj { if (!NOCASE_COMMAND_NAME_REGEX.test(name)) { problems.push( `Command name has special characters (${ - name.replace(/[0-9a-z_]/ig, "") + name.replace( + SuperExpressive() + .caseInsensitive + .allowMultipleMatches + .anyOf + .range("0", "9") + .range("a", "z") + .char("_") + .end() + .toRegex(), + "", + ) }). Only letters, digits and _ are allowed`, ); } From 9b6b74d1c3757ef292a2b51fe7fe7dcc81e9be2e Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 17:47:21 -0300 Subject: [PATCH 04/10] refactor: command matching regex into superExpressive, first iteration --- src/command.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/command.ts b/src/command.ts index 7f05a05..8e2db3c 100644 --- a/src/command.ts +++ b/src/command.ts @@ -393,11 +393,30 @@ export class Command implements MiddlewareObj { } const commandNames = ensureArray(command); - const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const commandRegex = new RegExp( - `${escapedPrefix}(?[^@ ]+)(?:@(?[^\\s]*))?(?.*)`, - "g", - ); + + const commandRegex = SuperExpressive().string(`${prefix}`) + .namedCapture("command") + .oneOrMore + .anythingButChars("@ ") + .end() + .optional.group + .char("@") + .subexpression( + SuperExpressive() + .namedCapture("username") + .zeroOrMore + .nonWhitespaceChar + .end(), + ) + .end() + .subexpression( + SuperExpressive() + .namedCapture("rest") + .zeroOrMore + .anyChar + .end(), + ) + .toRegex(); const firstCommand = commandRegex.exec(ctx.msg.text)?.groups; From dd0d64317f71cf9f6411ee56b50714d56283ba01 Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 18:49:20 -0300 Subject: [PATCH 05/10] refactor: second iteration --- src/command.ts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/command.ts b/src/command.ts index 8e2db3c..4a6f022 100644 --- a/src/command.ts +++ b/src/command.ts @@ -60,6 +60,16 @@ const NOCASE_COMMAND_NAME_REGEX = SuperExpressive() .endOfInput .toRegex(); +const SPECIAL_CHARS_REGEX = SuperExpressive() + .caseInsensitive + .allowMultipleMatches + .anyOf + .range("0", "9") + .range("a", "z") + .char("_") + .end() + .toRegex(); + /** * Class that represents a single command and allows you to configure it. */ @@ -208,18 +218,7 @@ export class Command implements MiddlewareObj { if (!NOCASE_COMMAND_NAME_REGEX.test(name)) { problems.push( `Command name has special characters (${ - name.replace( - SuperExpressive() - .caseInsensitive - .allowMultipleMatches - .anyOf - .range("0", "9") - .range("a", "z") - .char("_") - .end() - .toRegex(), - "", - ) + name.replace(SPECIAL_CHARS_REGEX, "") }). Only letters, digits and _ are allowed`, ); } @@ -394,11 +393,16 @@ export class Command implements MiddlewareObj { const commandNames = ensureArray(command); - const commandRegex = SuperExpressive().string(`${prefix}`) - .namedCapture("command") - .oneOrMore - .anythingButChars("@ ") - .end() + // THIS DROPS PERFORMANCE AS FK, it's recompiling the thing every function call + const commandRegex = SuperExpressive() + .string(`prefix`) + .subexpression( + SuperExpressive() + .namedCapture("command") + .oneOrMore + .anythingButChars("@ ") + .end(), + ) .optional.group .char("@") .subexpression( From 18cb3469dcf2128e198ffb801efb27556de2320e Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 20:01:00 -0300 Subject: [PATCH 06/10] ci: remove stack traces on tests --- deno.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.jsonc b/deno.jsonc index 67bb8da..95668a6 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,7 +21,7 @@ "backport": "deno run --no-prompt --allow-read=. --allow-write=. https://deno.land/x/deno2node@v1.14.0/src/cli.ts", "check": "deno lint && deno fmt --check && deno check --allow-import src/mod.ts", "fix": "deno lint --fix && deno fmt", - "test": "deno test --allow-import --seed=123456 --parallel ./test/", + "test": "deno test --allow-import --seed=123456 --parallel ./test/ --hide-stacktraces", "coverage": "rm -rf ./test/cov_profile && deno task test --coverage=./test/cov_profile && deno coverage --lcov --output=./coverage.lcov ./test/cov_profile", "hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts" }, From 65d93823f643e447aba81df68cd1e827147ca170 Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 20:30:45 -0300 Subject: [PATCH 07/10] refactor: logic for special chars. move regexes to own files --- src/command-group.ts | 9 ++---- src/command.ts | 66 +++++++++----------------------------------- src/context.ts | 5 ++-- src/utils/array.ts | 19 ------------- src/utils/regex.ts | 65 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 80 deletions(-) create mode 100644 src/utils/regex.ts diff --git a/src/command-group.ts b/src/command-group.ts index cb7b8eb..1f25c00 100644 --- a/src/command-group.ts +++ b/src/command-group.ts @@ -10,11 +10,8 @@ import { Middleware, } from "./deps.deno.ts"; import type { CommandElementals, CommandOptions } from "./types.ts"; -import { - ensureArray, - getCommandsRegex, - type MaybeArray, -} from "./utils/array.ts"; +import { ensureArray, type MaybeArray } from "./utils/array.ts"; +import { getCommandsLikeRegex } from "./utils/regex.ts"; import { setBotCommands, SetBotCommandsOptions, @@ -417,7 +414,7 @@ function containsCommands< } for (const prefix of allPrefixes) { - const regex = getCommandsRegex(prefix); + const regex = getCommandsLikeRegex(prefix); if (ctx.hasText(regex)) return true; } return false; diff --git a/src/command.ts b/src/command.ts index 4a6f022..bb3f258 100644 --- a/src/command.ts +++ b/src/command.ts @@ -12,7 +12,6 @@ import { type LanguageCode, type Middleware, type MiddlewareObj, - SuperExpressive, } from "./deps.deno.ts"; import type { CommandOptions } from "./types.ts"; import { ensureArray, type MaybeArray } from "./utils/array.ts"; @@ -23,6 +22,11 @@ import { matchesPattern, } from "./utils/checks.ts"; import { InvalidScopeError } from "./utils/errors.ts"; +import { + DISALLOWED_SPECIAL_CHARACTERS, + escapeEspecial, + NO_PREFIX_COMMAND_MATCHER, +} from "./utils/regex.ts"; type BotCommandGroupsScope = | BotCommandScopeAllGroupChats @@ -48,28 +52,6 @@ export interface CommandMatch { match?: RegExpExecArray | null; } -const NOCASE_COMMAND_NAME_REGEX = SuperExpressive() - .caseInsensitive - .oneOrMore - .startOfInput - .anyOf - .range("0", "9") - .range("a", "z") - .char("_") - .end() - .endOfInput - .toRegex(); - -const SPECIAL_CHARS_REGEX = SuperExpressive() - .caseInsensitive - .allowMultipleMatches - .anyOf - .range("0", "9") - .range("a", "z") - .char("_") - .end() - .toRegex(); - /** * Class that represents a single command and allows you to configure it. */ @@ -215,10 +197,10 @@ export class Command implements MiddlewareObj { ); } - if (!NOCASE_COMMAND_NAME_REGEX.test(name)) { + if (DISALLOWED_SPECIAL_CHARACTERS.test(name)) { problems.push( `Command name has special characters (${ - name.replace(SPECIAL_CHARS_REGEX, "") + name.match(DISALLOWED_SPECIAL_CHARACTERS)?.join("") }). Only letters, digits and _ are allowed`, ); } @@ -393,34 +375,12 @@ export class Command implements MiddlewareObj { const commandNames = ensureArray(command); - // THIS DROPS PERFORMANCE AS FK, it's recompiling the thing every function call - const commandRegex = SuperExpressive() - .string(`prefix`) - .subexpression( - SuperExpressive() - .namedCapture("command") - .oneOrMore - .anythingButChars("@ ") - .end(), - ) - .optional.group - .char("@") - .subexpression( - SuperExpressive() - .namedCapture("username") - .zeroOrMore - .nonWhitespaceChar - .end(), - ) - .end() - .subexpression( - SuperExpressive() - .namedCapture("rest") - .zeroOrMore - .anyChar - .end(), - ) - .toRegex(); + const escapedPrefix = escapeEspecial(prefix); + + const commandRegex = new RegExp( + escapedPrefix + NO_PREFIX_COMMAND_MATCHER.source, + "g", + ); const firstCommand = commandRegex.exec(ctx.msg.text)?.groups; diff --git a/src/context.ts b/src/context.ts index b15e72e..f411393 100644 --- a/src/context.ts +++ b/src/context.ts @@ -3,7 +3,8 @@ import { CommandMatch } from "./command.ts"; import { BotCommandScopeChat, Context, NextFunction } from "./deps.deno.ts"; import { SetMyCommandsParams } from "./mod.ts"; import { BotCommandEntity } from "./types.ts"; -import { ensureArray, getCommandsRegex } from "./utils/array.ts"; +import { ensureArray } from "./utils/array.ts"; +import { getCommandsLikeRegex } from "./utils/regex.ts"; import { fuzzyMatch, JaroWinklerOptions } from "./utils/jaro-winkler.ts"; import { setBotCommands, @@ -141,7 +142,7 @@ export function commands() { if (!prefixes.length) return []; const regexes = prefixes.map( - (prefix) => getCommandsRegex(prefix), + (prefix) => getCommandsLikeRegex(prefix), ); const entities = regexes.flatMap((regex) => { let match: RegExpExecArray | null; diff --git a/src/utils/array.ts b/src/utils/array.ts index add2c87..e31e04e 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -1,22 +1,3 @@ -import { SuperExpressive } from "../deps.deno.ts"; - export type MaybeArray = T | T[]; export const ensureArray = (value: MaybeArray): T[] => Array.isArray(value) ? value : [value]; - -export function getCommandsRegex(prefix: string) { - return SuperExpressive() - .assertNotBehind - .nonWhitespaceChar - .end() - .namedCapture("prefix") - .string(`${prefix}`) - .end() - .oneOrMore - .nonWhitespaceChar - .anyOf - .whitespaceChar - .endOfInput - .end() - .toRegex(); -} diff --git a/src/utils/regex.ts b/src/utils/regex.ts new file mode 100644 index 0000000..3a9dd3c --- /dev/null +++ b/src/utils/regex.ts @@ -0,0 +1,65 @@ +import { SuperExpressive } from "../deps.deno.ts"; + +export const escapeEspecial = (prefix: string) => + SuperExpressive().namedCapture("prefix") + .string(`${prefix}`) + .end() + .toRegex() + .source; + +const notBehindWhitespace = SuperExpressive() + .assertNotBehind + .nonWhitespaceChar + .end() + .toRegex() + .source; + +const hasCharsAndDetectEnd = SuperExpressive() + .oneOrMore + .nonWhitespaceChar + .anyOf + .whitespaceChar + .endOfInput + .end() + .toRegex() + .source; + +export function getCommandsLikeRegex(prefix: string) { + return new RegExp( + notBehindWhitespace + escapeEspecial(prefix) + hasCharsAndDetectEnd, + "g", + ); +} + +export const DISALLOWED_SPECIAL_CHARACTERS = SuperExpressive() + .caseInsensitive + .allowMultipleMatches + .anythingBut + .range("0", "9") + .range("a", "z") + .char("_") + .end() + .toRegex(); + +export const NO_PREFIX_COMMAND_MATCHER = SuperExpressive() + .namedCapture("command") + .oneOrMore + .anythingButChars("@ ") + .end() + .optional.group + .char("@") + .subexpression( + SuperExpressive() + .namedCapture("username") + .zeroOrMore + .nonWhitespaceChar + .end(), + ) + .end() + .subexpression( + SuperExpressive() + .namedCapture("rest") + .zeroOrMore + .anyChar + .end(), + ).toRegex(); From 7657967814dc4037078f3278b2f125fdb9a5cfcd Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Wed, 12 Feb 2025 20:36:17 -0300 Subject: [PATCH 08/10] refactor: special characters problem message --- src/command.ts | 4 ++-- test/command.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/command.ts b/src/command.ts index bb3f258..a5e303a 100644 --- a/src/command.ts +++ b/src/command.ts @@ -199,9 +199,9 @@ export class Command implements MiddlewareObj { if (DISALLOWED_SPECIAL_CHARACTERS.test(name)) { problems.push( - `Command name has special characters (${ + `Command name contains the following disallowed special characters: ${ name.match(DISALLOWED_SPECIAL_CHARACTERS)?.join("") - }). Only letters, digits and _ are allowed`, + }\nOnly letters, digits and _ are allowed`, ); } } diff --git a/test/command.test.ts b/test/command.test.ts index 1f7f23f..b9f005d 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -605,7 +605,7 @@ describe("Command", () => { const command = new Command("*test!", "_"); assertEquals(command.isApiCompliant(), [ false, - "Command name has special characters (*!). Only letters, digits and _ are allowed", + "Command name contains the following disallowed special characters: *!\nOnly letters, digits and _ are allowed", ]); }); @@ -617,7 +617,7 @@ describe("Command", () => { assertEquals(command.isApiCompliant(), [ false, "Command name has uppercase characters", - "Command name has special characters ($). Only letters, digits and _ are allowed", + "Command name contains the following disallowed special characters: $\nOnly letters, digits and _ are allowed", ]); }); }); From 6bd304831af67bbd6c241937b4bedd5e56e59f38 Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Thu, 13 Feb 2025 20:03:16 -0300 Subject: [PATCH 09/10] refactor: move helper functions to new file --- test/command-group.test.ts | 14 ++++---- test/context.test.ts | 52 ++++----------------------- test/integration.test.ts | 44 ++--------------------- test/jaroWrinkler.test.ts | 42 +++++++++++----------- test/not-found.test.ts | 10 +++--- test/utils-test.test.ts | 2 +- test/utils.ts | 73 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 122 deletions(-) create mode 100644 test/utils.ts diff --git a/test/command-group.test.ts b/test/command-group.test.ts index b3c12d5..aec61b1 100644 --- a/test/command-group.test.ts +++ b/test/command-group.test.ts @@ -1,6 +1,6 @@ import { CommandGroup } from "../src/command-group.ts"; import { MyCommandParams } from "../src/mod.ts"; -import { dummyCtx } from "./context.test.ts"; +import { getDummyCtx } from "./utils.ts"; import { assert, assertEquals, @@ -54,7 +54,7 @@ describe("CommandGroup", () => { }); describe("setMyCommands", () => { it("should throw if the update has no chat property", () => { - const ctx = dummyCtx({ noMessage: true }); + const ctx = getDummyCtx({ noMessage: true }); const a = new CommandGroup(); assertRejects(() => ctx.setMyCommands(a)); }); @@ -294,7 +294,7 @@ describe("CommandGroup", () => { it("should only consider as entities prefixes registered in the command instance", () => { const text = "/papi hola papacito como estamos /papi /ecco"; - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: text, }); const entities = ctx.getCommandEntities(a); @@ -310,7 +310,7 @@ describe("CommandGroup", () => { } }); it("should get command entities for custom prefixes", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/hi ?momi abcdfghi", }); const entities = ctx.getCommandEntities(a); @@ -339,15 +339,15 @@ describe("CommandGroup", () => { ]); }); it("should throw if you call getCommandEntities on an update with no text", () => { - const ctx = dummyCtx({}); + const ctx = getDummyCtx({}); assertThrows(() => ctx.getCommandEntities([a, b, c])); }); it("should return an empty array if the Commands classes to check against do not have any command register", () => { - const ctx = dummyCtx({ userInput: "/papi" }); + const ctx = getDummyCtx({ userInput: "/papi" }); assertEquals(ctx.getCommandEntities(c), []); }); it("should work across multiple Commands instances", () => { - const ctx = dummyCtx({ userInput: "/papi superprefixmami" }); + const ctx = getDummyCtx({ userInput: "/papi superprefixmami" }); assertEquals( ctx.getCommandEntities([a, b]).map((entity) => entity.prefix), ["/", "superprefix"], diff --git a/test/context.test.ts b/test/context.test.ts index 44c93e1..971873f 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -1,25 +1,10 @@ -import { - resolvesNext, - spy, -} from "https://deno.land/std@0.203.0/testing/mock.ts"; -import { commands, type CommandsFlavor } from "../src/mod.ts"; -import { - Api, - assert, - assertRejects, - Chat, - Context, - describe, - it, - Message, - Update, - User, - UserFromGetMe, -} from "./deps.test.ts"; +import { commands } from "../src/mod.ts"; +import { assert, assertRejects, describe, it } from "./deps.test.ts"; +import { getDummyCtx } from "./utils.ts"; describe("commands", () => { it("should install the setMyCommands method on the context", () => { - const context = dummyCtx({}); + const context = getDummyCtx({}); const middleware = commands(); middleware(context, async () => {}); @@ -27,7 +12,7 @@ describe("commands", () => { assert(context.setMyCommands); }); it("should install the getNearestCommand method on the context", () => { - const context = dummyCtx({}); + const context = getDummyCtx({}); const middleware = commands(); middleware(context, async () => {}); @@ -37,7 +22,7 @@ describe("commands", () => { describe("setMyCommands", () => { it("should throw an error if there is no chat", async () => { - const context = dummyCtx({ noMessage: true }); + const context = getDummyCtx({ noMessage: true }); const middleware = commands(); middleware(context, async () => {}); @@ -50,28 +35,3 @@ describe("commands", () => { }); }); }); - -export function dummyCtx({ userInput, language, noMessage }: { - userInput?: string; - language?: string; - noMessage?: boolean; -}) { - const u = { id: 42, first_name: "yo", language_code: language } as User; - const c = { id: 100, type: "private" } as Chat; - const m = noMessage ? undefined : ({ - text: userInput, - from: u, - chat: c, - } as Message); - const update = { - message: m, - } as Update; - const api = { - raw: { setMyCommands: spy(resolvesNext([true] as const)) }, - } as unknown as Api; - const me = { id: 42, username: "bot" } as UserFromGetMe; - const ctx = new Context(update, api, me) as CommandsFlavor; - const middleware = commands(); - middleware(ctx, async () => {}); - return ctx; -} diff --git a/test/integration.test.ts b/test/integration.test.ts index 3a006e0..51331c1 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -3,56 +3,16 @@ import { resolvesNext, } from "https://deno.land/std@0.203.0/testing/mock.ts"; import { CommandGroup } from "../src/command-group.ts"; -import { Bot } from "../src/deps.deno.ts"; -import { Command, commands, CommandsFlavor } from "../src/mod.ts"; +import { Command, commands } from "../src/mod.ts"; import { Api, assertRejects, assertSpyCall, - Chat, - Context, describe, it, - Message, spy, - Update, - User, } from "./deps.test.ts"; - -const getBot = () => - new Bot("dummy_token", { - botInfo: { - id: 1, - is_bot: true, - username: "", - can_join_groups: true, - can_read_all_group_messages: true, - supports_inline_queries: true, - first_name: "", - can_connect_to_business: true, - has_main_web_app: false, - }, - }); - -const getDummyUpdate = ({ userInput, language, noChat, chatType = "private" }: { - userInput?: string; - language?: string; - noChat?: boolean; - chatType?: Chat["type"]; -} = {}) => { - const u = { id: 42, first_name: "yo", language_code: language } as User; - const c = { id: 100, type: chatType } as Chat; - const m = { - text: userInput, - from: u, - chat: noChat ? undefined : c, - } as Message; - const update = { - message: m, - } as Update; - - return update; -}; +import { getBot, getDummyUpdate } from "./utils.ts"; describe("Integration", () => { describe("setCommands", () => { diff --git a/test/jaroWrinkler.test.ts b/test/jaroWrinkler.test.ts index c282724..fc833c5 100644 --- a/test/jaroWrinkler.test.ts +++ b/test/jaroWrinkler.test.ts @@ -4,7 +4,7 @@ import { JaroWinklerDistance, } from "../src/utils/jaro-winkler.ts"; import { CommandGroup } from "../src/mod.ts"; -import { dummyCtx } from "./context.test.ts"; +import { getDummyCtx } from "./utils.ts"; import { assertEquals, assertThrows, @@ -344,13 +344,13 @@ describe("Jaro-Wrinkler Algorithm", () => { cmds.command("entitle", "_", () => {}); it("should throw when no msg is given", () => { - let ctx = dummyCtx({}); + let ctx = getDummyCtx({}); assertThrows(() => ctx.getNearestCommand(cmds)); }); describe("should ignore localization when set to, and search trough all commands", () => { it("ignore even if the language is set", () => { // should this console.warn? or maybe use an overload? - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/duci", language: "es", }); @@ -360,7 +360,7 @@ describe("Jaro-Wrinkler Algorithm", () => { }), "/duc", ); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/duki", language: "es", }); @@ -372,7 +372,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ); }); it("ignore when the language is not set", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/duki", language: "es", }); @@ -380,7 +380,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ctx.getNearestCommand(cmds, { ignoreLocalization: true }), "/duke", ); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/macellaoo", language: "es", }); @@ -388,7 +388,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ctx.getNearestCommand(cmds, { ignoreLocalization: true }), "+macellaio", ); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/dadd", language: "es", }); @@ -396,7 +396,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ctx.getNearestCommand(cmds, { ignoreLocalization: true }), "?daddy", ); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/duk", language: "es", }); @@ -406,7 +406,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ); }); it("should not restrict itself to default", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/duqu", language: "es", }); @@ -416,7 +416,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ); }); it("language not know, but ignore localization still matches the best similarity", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/duqu", language: "en-papacito", }); @@ -426,7 +426,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ); }); it("should chose localization if not ignore", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/duku", language: "es", }); @@ -434,7 +434,7 @@ describe("Jaro-Wrinkler Algorithm", () => { ctx.getNearestCommand(cmds), "/duque", ); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/duk", language: "fr", }); @@ -446,12 +446,12 @@ describe("Jaro-Wrinkler Algorithm", () => { }); describe("should not fail even if the language it's not know", () => { it("should fallback to default", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/duko", language: "en-papacito", }); assertEquals(ctx.getNearestCommand(cmds), "/duke"); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/butxher", language: "no-language", }); @@ -460,21 +460,21 @@ describe("Jaro-Wrinkler Algorithm", () => { }); describe("should work for commands with no localization, even when the language is set", () => { it("ender", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/endr", language: "es", }); assertEquals(ctx.getNearestCommand(cmds), "/ender"); }); it("endanger", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/enanger", language: "en", }); assertEquals(ctx.getNearestCommand(cmds), "/endanger"); }); it("entitle", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/entities", language: "pt", }); @@ -495,19 +495,19 @@ describe("Jaro-Wrinkler Algorithm", () => { .localize("fr", "pere", "_"); it("should get the nearest between multiple command classes", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/papi", language: "es", }); assertEquals(ctx.getNearestCommand([cmds, cmds2]), "/papa"); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/pai", language: "fr", }); assertEquals(ctx.getNearestCommand([cmds, cmds2]), "/pain"); }); it("Without localization it should get the best between multiple command classes", () => { - let ctx = dummyCtx({ + let ctx = getDummyCtx({ userInput: "/pana", language: "???", }); @@ -517,7 +517,7 @@ describe("Jaro-Wrinkler Algorithm", () => { }), "/pan", ); - ctx = dummyCtx({ + ctx = getDummyCtx({ userInput: "/para", language: "???", }); diff --git a/test/not-found.test.ts b/test/not-found.test.ts index b9bbc94..ff5c72a 100644 --- a/test/not-found.test.ts +++ b/test/not-found.test.ts @@ -1,6 +1,6 @@ import { CommandsFlavor } from "../src/context.ts"; import { CommandGroup, commandNotFound } from "../src/mod.ts"; -import { dummyCtx } from "./context.test.ts"; +import { getDummyCtx } from "./utils.ts"; import { assert, assertEquals, @@ -12,7 +12,7 @@ import { describe("commandNotFound", () => { describe("for inputs containing '/' commands", () => { - const ctx = dummyCtx({ userInput: "/papacin /papazote" }); + const ctx = getDummyCtx({ userInput: "/papacin /papazote" }); it("should return true when no commands are registered", () => { const cmds = new CommandGroup(); const predicate = commandNotFound(cmds); @@ -32,7 +32,7 @@ describe("commandNotFound", () => { }); }); describe("for inputs containing custom prefixed commands", () => { - const ctx = dummyCtx({ userInput: "?papacin +papazote" }); + const ctx = getDummyCtx({ userInput: "?papacin +papazote" }); it("should return false if only '/' commands are registered", () => { const cmds = new CommandGroup(); @@ -72,12 +72,12 @@ describe("commandNotFound", () => { const predicate = commandNotFound(cmds); it("should contain the proper suggestion ", () => { - const ctx = dummyCtx({ userInput: "/papacin" }) as withSuggestion; + const ctx = getDummyCtx({ userInput: "/papacin" }) as withSuggestion; predicate(ctx); assertEquals(ctx.commandSuggestion, "+papacin"); }); it("should be null when the input does not match a suggestion", () => { - const ctx = dummyCtx({ + const ctx = getDummyCtx({ userInput: "/nonadapapi", }) as withSuggestion; predicate(ctx); diff --git a/test/utils-test.test.ts b/test/utils-test.test.ts index 2c8ff47..20a88ac 100644 --- a/test/utils-test.test.ts +++ b/test/utils-test.test.ts @@ -1,7 +1,7 @@ import { isMiddleware } from "../src/utils/checks.ts"; import { CommandsFlavor } from "../src/context.ts"; import { CommandGroup, commandNotFound } from "../src/mod.ts"; -import { dummyCtx } from "./context.test.ts"; +import { getDummyCtx } from "./utils.ts"; import { assert, assertEquals, diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..0053588 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,73 @@ +import { resolvesNext } from "https://deno.land/std@0.203.0/testing/mock.ts"; +import { commands, CommandsFlavor } from "../src/context.ts"; +import { Api, Bot, Context } from "../src/deps.deno.ts"; +import { + Chat, + Message, + spy, + Update, + User, + UserFromGetMe, +} from "./deps.test.ts"; + +export const getBot = () => + new Bot("dummy_token", { + botInfo: { + id: 1, + is_bot: true, + username: "", + can_join_groups: true, + can_read_all_group_messages: true, + supports_inline_queries: true, + first_name: "", + can_connect_to_business: true, + has_main_web_app: false, + }, + }); + +export const getDummyUpdate = ( + { userInput, language, noChat, chatType = "private" }: { + userInput?: string; + language?: string; + noChat?: boolean; + chatType?: Chat["type"]; + } = {}, +) => { + const u = { id: 42, first_name: "yo", language_code: language } as User; + const c = { id: 100, type: chatType } as Chat; + const m = { + text: userInput, + from: u, + chat: noChat ? undefined : c, + } as Message; + const update = { + message: m, + } as Update; + + return update; +}; + +export function getDummyCtx({ userInput, language, noMessage }: { + userInput?: string; + language?: string; + noMessage?: boolean; +}) { + const u = { id: 42, first_name: "yo", language_code: language } as User; + const c = { id: 100, type: "private" } as Chat; + const m = noMessage ? undefined : ({ + text: userInput, + from: u, + chat: c, + } as Message); + const update = { + message: m, + } as Update; + const api = { + raw: { setMyCommands: spy(resolvesNext([true] as const)) }, + } as unknown as Api; + const me = { id: 42, username: "bot" } as UserFromGetMe; + const ctx = new Context(update, api, me) as CommandsFlavor; + const middleware = commands(); + middleware(ctx, async () => {}); + return ctx; +} From 707d39b98abea9175aba6a604bcdd4aff50b379d Mon Sep 17 00:00:00 2001 From: Hero Protagonist Date: Thu, 13 Feb 2025 20:57:00 -0300 Subject: [PATCH 10/10] test: add performance test --- test/perf.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/perf.test.ts diff --git a/test/perf.test.ts b/test/perf.test.ts new file mode 100644 index 0000000..128df39 --- /dev/null +++ b/test/perf.test.ts @@ -0,0 +1,53 @@ +import { Command as Command_HEAD, CommandOptions } from "../src/mod.ts"; +import { + Command as Command_MAIN, +} from "https://github.com/grammyjs/commands/raw/main/src/mod.ts"; +import { assert, assertEquals, describe, it } from "./deps.test.ts"; +import { getDummyCtx } from "./utils.ts"; + +describe("Command Matching stress test", () => { + const options: CommandOptions = { + matchOnlyAtStart: true, + prefix: "/", + targetedCommands: "optional", + ignoreCase: false, + }; + + const ctx = getDummyCtx({ userInput: "/start" }); + const m = ctx.message!; + + it("Assert new implamantation is not more than 10% slower", () => { + let iterations = 100000; + const init_HEAD = Date.now(); + while (iterations) { + assertEquals( + Command_HEAD.findMatchingCommand("start", options, ctx), + { + command: "start", + rest: "", + }, + ); + iterations--; + } + const finish_HEAD = Date.now(); + + iterations = 100000; + const init_MAIN = Date.now(); + while (iterations) { + assertEquals( + Command_MAIN.findMatchingCommand("start", options, ctx), + { + command: "start", + rest: "", + }, + ); + iterations--; + } + const finish_MAIN = Date.now(); + + const HEAD_TIME = finish_HEAD - init_HEAD; + const MAIN_TIME = finish_MAIN - init_MAIN; + + assert(HEAD_TIME <= MAIN_TIME + (MAIN_TIME / 100) * 10); + }); +});