fix: faster autocomplete lookup (#387)

* fix:faster-autocmp

* fixinitializing

* fix

* fixonwindows

* unconsole
This commit is contained in:
Jacob Nguyen
2025-02-02 19:37:59 -06:00
committed by GitHub
parent 3a569726d8
commit 974c30fa6c
7 changed files with 393 additions and 342 deletions

84
test/autocomp.bench.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe } from 'node:test'
import { bench } from 'vitest'
import { SernAutocompleteData, SernOptionsData } from '../src'
import { createRandomChoice } from './setup/util'
import { ApplicationCommandOptionType, AutocompleteFocusedOption, AutocompleteInteraction } from 'discord.js'
import { createLookupTable } from '../src/core/functions'
import assert from 'node:assert'
/**
* Uses an iterative DFS to check if an autocomplete node exists on the option tree
* This is the old internal method that sern used to resolve autocomplete
* @param iAutocomplete
* @param options
*/
function treeSearch(
choice: AutocompleteFocusedOption,
parent: string|undefined,
options: SernOptionsData[] | undefined,
): SernAutocompleteData & { parent?: string } | undefined {
if (options === undefined) return undefined;
//clone to prevent mutation of original command module
const _options = options.map(a => ({ ...a }));
const subcommands = new Set();
while (_options.length > 0) {
const cur = _options.pop()!;
switch (cur.type) {
case ApplicationCommandOptionType.Subcommand: {
subcommands.add(cur.name);
for (const option of cur.options ?? []) _options.push(option);
} break;
case ApplicationCommandOptionType.SubcommandGroup: {
for (const command of cur.options ?? []) _options.push(command);
} break;
default: {
if ('autocomplete' in cur && cur.autocomplete) {
assert( 'command' in cur, 'No `command` property found for option ' + cur.name);
if (subcommands.size > 0) {
const parentAndOptionMatches =
subcommands.has(parent) && cur.name === choice.name;
if (parentAndOptionMatches) {
return { ...cur, parent };
}
} else {
if (cur.name === choice.name) {
return { ...cur, parent: undefined };
}
}
}
} break;
}
}
}
const options: SernOptionsData[] = [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: () => {} },
},
]
const table = createLookupTable(options)
describe('autocomplete lookup', () => {
bench('lookup table', () => {
table.get('<parent>/autocomplete')
}, { time: 500 })
bench('naive treeSearch', () => {
treeSearch({ focused: true,
name: 'autocomplete',
value: 'autocomplete',
type: ApplicationCommandOptionType.String }, undefined, options)
}, { time: 500 })
})

View File

@@ -1,29 +1,17 @@
//@ts-nocheck
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PluginType, SernOptionsData, controller } from '../../src/index';
import { partitionPlugins, treeSearch } from '../../src/core/functions';
import { createLookupTable, partitionPlugins, treeSearch } from '../../src/core/functions';
import { faker } from '@faker-js/faker';
import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js';
import { createRandomChoice, createRandomPlugins } from '../setup/util';
describe('functions', () => {
afterEach(() => {
vi.clearAllMocks();
});
function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random() * 2) + 1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({
type: random(),
execute: () => (random() === 1 ? controller.next() : controller.stop()),
}));
}
function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11 }),
name: faker.word.noun(),
description: faker.word.adjective(),
};
}
it('should partition plugins correctly', () => {
const plugins = createRandomPlugins(100);
const [onEvent, init] = partitionPlugins(plugins);
@@ -32,308 +20,275 @@ describe('functions', () => {
for (const el of init) expect(el.type).to.equal(PluginType.Init);
});
it('should tree search options tree depth 1', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('autocomplete');
const options: SernOptionsData[] = [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: vi.fn() },
},
];
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'autocomplete',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('autocomplete');
expect(result.command).to.be.not.undefined;
}),
it('should tree search depth 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
describe('autocomplete', ( ) => {
it('should tree search options tree depth 1', () => {
const options: SernOptionsData[] = [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: vi.fn() },
},
];
const table = createLookupTable(options)
const result = table.get('<parent>/autocomplete')
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('autocomplete');
expect(result.command).to.be.not.undefined;
}),
it('should tree search depth 2', () => {
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${subcommandName}/nested`)
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should tree search depth n > 2', () => {
const subgroupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
type: ApplicationCommandOptionType.SubcommandGroup,
name: subgroupName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
createRandomChoice(),
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
const table = createLookupTable(options)
const result = table.get(`<parent>/${subgroupName}/${subcommandName}/nested`)
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should tree search depth n > 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
it('should correctly resolve suboption of the same name given two subcommands ', () => {
const subcommandName = faker.string.alpha();
const groupname = faker.string.alpha()
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: groupname,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
},
createRandomChoice(),
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${groupname}/${subcommandName}/nested`);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should correctly resolve suboption of the same name given two subcommands ', () => {
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
it('two subcommands with an option of the same name', () => {
const groupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: groupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'anothera',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
},
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('two subcommands with an option of the same name', () => {
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${groupName}/${subcommandName}/nested`);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
let autocmpInteraction2 = new AutocompleteInteraction('nested');
autocmpInteraction2.options.getSubcommand.mockReturnValue(subcommandName + 'a');
autocmpInteraction2.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result2 = treeSearch(autocmpInteraction2, options);
expect(result2).toBeTruthy();
expect(result2?.name).toEqual('nested');
});
it('simulates autocomplete typing and resolution', () => {
const subcommandName = faker.string.alpha();
const optionName = faker.word.noun();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
it('simulates autocomplete typing and resolution', () => {
const subcommandGroupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const optionName = faker.word.noun();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: subcommandGroupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
},
],
},
],
},
];
let accumulator = '';
let result: unknown;
for (const char of optionName) {
accumulator += char;
],
},
],
},
];
let accumulator = '';
let result: unknown;
const table = createLookupTable(options)
for (const char of optionName) {
accumulator += char;
const focusedValue = {
name: accumulator,
value: faker.string.alpha(),
focused: true,
};
result = table.get(`<parent>/${subcommandGroupName}/${subcommandName}/${focusedValue.name}` );
}
expect(result).toBeTruthy();
});
})
const autocomplete = new AutocompleteInteraction(accumulator);
autocomplete.options.getSubcommand.mockReturnValue(subcommandName);
autocomplete.options.getFocused.mockReturnValue({
name: accumulator,
value: faker.string.alpha(),
focused: true,
});
result = treeSearch(autocomplete, options);
}
expect(result).toBeTruthy();
});
});

View File

@@ -28,3 +28,21 @@ export function createRandomModule(plugins: any[]): Processed<Module> {
execute: vi.fn(),
};
}
export function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11 }),
name: faker.word.noun(),
description: faker.word.adjective(),
};
}
export function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random() * 2) + 1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({
type: random(),
execute: () => (random() === 1 ? controller.next() : controller.stop()),
}));
}