- Add currentModel to callback handler for checkmark display - Add 64-byte callback_data limit protection (skip long model IDs) - Add tests for large model lists and callback_data limits
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildModelsKeyboard,
|
|
buildProviderKeyboard,
|
|
buildBrowseProvidersButton,
|
|
calculateTotalPages,
|
|
getModelsPageSize,
|
|
parseModelCallbackData,
|
|
type ProviderInfo,
|
|
} from "./model-buttons.js";
|
|
|
|
describe("parseModelCallbackData", () => {
|
|
it("parses mdl_prov callback", () => {
|
|
const result = parseModelCallbackData("mdl_prov");
|
|
expect(result).toEqual({ type: "providers" });
|
|
});
|
|
|
|
it("parses mdl_back callback", () => {
|
|
const result = parseModelCallbackData("mdl_back");
|
|
expect(result).toEqual({ type: "back" });
|
|
});
|
|
|
|
it("parses mdl_list callback with provider and page", () => {
|
|
const result = parseModelCallbackData("mdl_list_anthropic_2");
|
|
expect(result).toEqual({ type: "list", provider: "anthropic", page: 2 });
|
|
});
|
|
|
|
it("parses mdl_list callback with hyphenated provider", () => {
|
|
const result = parseModelCallbackData("mdl_list_open-ai_1");
|
|
expect(result).toEqual({ type: "list", provider: "open-ai", page: 1 });
|
|
});
|
|
|
|
it("parses mdl_sel callback with provider/model", () => {
|
|
const result = parseModelCallbackData("mdl_sel_anthropic/claude-sonnet-4-5");
|
|
expect(result).toEqual({
|
|
type: "select",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4-5",
|
|
});
|
|
});
|
|
|
|
it("parses mdl_sel callback with nested model path", () => {
|
|
const result = parseModelCallbackData("mdl_sel_openai/gpt-4/turbo");
|
|
expect(result).toEqual({
|
|
type: "select",
|
|
provider: "openai",
|
|
model: "gpt-4/turbo",
|
|
});
|
|
});
|
|
|
|
it("returns null for non-model callback data", () => {
|
|
expect(parseModelCallbackData("commands_page_1")).toBeNull();
|
|
expect(parseModelCallbackData("other_callback")).toBeNull();
|
|
expect(parseModelCallbackData("")).toBeNull();
|
|
});
|
|
|
|
it("returns null for invalid mdl_ patterns", () => {
|
|
expect(parseModelCallbackData("mdl_invalid")).toBeNull();
|
|
expect(parseModelCallbackData("mdl_list_")).toBeNull();
|
|
expect(parseModelCallbackData("mdl_sel_noslash")).toBeNull();
|
|
});
|
|
|
|
it("handles whitespace in callback data", () => {
|
|
expect(parseModelCallbackData(" mdl_prov ")).toEqual({ type: "providers" });
|
|
});
|
|
});
|
|
|
|
describe("buildProviderKeyboard", () => {
|
|
it("returns empty array for no providers", () => {
|
|
const result = buildProviderKeyboard([]);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("builds single provider as one row", () => {
|
|
const providers: ProviderInfo[] = [{ id: "anthropic", count: 5 }];
|
|
const result = buildProviderKeyboard(providers);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toHaveLength(1);
|
|
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
|
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_list_anthropic_1");
|
|
});
|
|
|
|
it("builds two providers per row", () => {
|
|
const providers: ProviderInfo[] = [
|
|
{ id: "anthropic", count: 5 },
|
|
{ id: "openai", count: 8 },
|
|
];
|
|
const result = buildProviderKeyboard(providers);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toHaveLength(2);
|
|
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
|
|
expect(result[0]?.[1]?.text).toBe("openai (8)");
|
|
});
|
|
|
|
it("wraps to next row after two providers", () => {
|
|
const providers: ProviderInfo[] = [
|
|
{ id: "anthropic", count: 5 },
|
|
{ id: "openai", count: 8 },
|
|
{ id: "google", count: 3 },
|
|
];
|
|
const result = buildProviderKeyboard(providers);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toHaveLength(2);
|
|
expect(result[1]).toHaveLength(1);
|
|
expect(result[1]?.[0]?.text).toBe("google (3)");
|
|
});
|
|
});
|
|
|
|
describe("buildModelsKeyboard", () => {
|
|
it("shows back button for empty models", () => {
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: [],
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.[0]?.text).toBe("<< Back");
|
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_back");
|
|
});
|
|
|
|
it("shows models with one per row", () => {
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: ["claude-sonnet-4", "claude-opus-4"],
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
// 2 model rows + back button
|
|
expect(result).toHaveLength(3);
|
|
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4");
|
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
|
|
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
|
expect(result[2]?.[0]?.text).toBe("<< Back");
|
|
});
|
|
|
|
it("marks current model with checkmark", () => {
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: ["claude-sonnet-4", "claude-opus-4"],
|
|
currentModel: "anthropic/claude-sonnet-4",
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4 ✓");
|
|
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
|
});
|
|
|
|
it("shows pagination when multiple pages", () => {
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: ["model1", "model2"],
|
|
currentPage: 1,
|
|
totalPages: 3,
|
|
pageSize: 2,
|
|
});
|
|
// 2 model rows + pagination row + back button
|
|
expect(result).toHaveLength(4);
|
|
const paginationRow = result[2];
|
|
expect(paginationRow).toHaveLength(2); // no prev on first page
|
|
expect(paginationRow?.[0]?.text).toBe("1/3");
|
|
expect(paginationRow?.[1]?.text).toBe("Next ▶");
|
|
});
|
|
|
|
it("shows prev and next on middle pages", () => {
|
|
// 6 models with pageSize 2 = 3 pages
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
|
currentPage: 2,
|
|
totalPages: 3,
|
|
pageSize: 2,
|
|
});
|
|
// 2 model rows + pagination row + back button
|
|
expect(result).toHaveLength(4);
|
|
const paginationRow = result[2];
|
|
expect(paginationRow).toHaveLength(3);
|
|
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
|
|
expect(paginationRow?.[1]?.text).toBe("2/3");
|
|
expect(paginationRow?.[2]?.text).toBe("Next ▶");
|
|
});
|
|
|
|
it("shows only prev on last page", () => {
|
|
// 6 models with pageSize 2 = 3 pages
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
|
currentPage: 3,
|
|
totalPages: 3,
|
|
pageSize: 2,
|
|
});
|
|
// 2 model rows + pagination row + back button
|
|
expect(result).toHaveLength(4);
|
|
const paginationRow = result[2];
|
|
expect(paginationRow).toHaveLength(2);
|
|
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
|
|
expect(paginationRow?.[1]?.text).toBe("3/3");
|
|
});
|
|
|
|
it("truncates long model IDs for display", () => {
|
|
// Model ID that's long enough to truncate display but still fits in callback_data
|
|
// callback_data = "mdl_sel_anthropic/" (18) + model (<=46) = 64 max
|
|
const longModel = "claude-3-5-sonnet-20241022-with-suffix";
|
|
const result = buildModelsKeyboard({
|
|
provider: "anthropic",
|
|
models: [longModel],
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
const text = result[0]?.[0]?.text;
|
|
// Model is 38 chars, fits exactly in 38-char display limit
|
|
expect(text).toBe(longModel);
|
|
});
|
|
|
|
it("truncates display text for very long model names", () => {
|
|
// Use short provider to allow longer model in callback_data (64 byte limit)
|
|
// "mdl_sel_a/" = 10 bytes, leaving 54 for model
|
|
const longModel = "this-model-name-is-long-enough-to-need-truncation-abcd";
|
|
const result = buildModelsKeyboard({
|
|
provider: "a",
|
|
models: [longModel],
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
const text = result[0]?.[0]?.text;
|
|
expect(text?.startsWith("…")).toBe(true);
|
|
expect(text?.length).toBeLessThanOrEqual(38);
|
|
});
|
|
});
|
|
|
|
describe("buildBrowseProvidersButton", () => {
|
|
it("returns browse providers button", () => {
|
|
const result = buildBrowseProvidersButton();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toHaveLength(1);
|
|
expect(result[0]?.[0]?.text).toBe("Browse providers");
|
|
expect(result[0]?.[0]?.callback_data).toBe("mdl_prov");
|
|
});
|
|
});
|
|
|
|
describe("getModelsPageSize", () => {
|
|
it("returns default page size", () => {
|
|
expect(getModelsPageSize()).toBe(8);
|
|
});
|
|
});
|
|
|
|
describe("calculateTotalPages", () => {
|
|
it("calculates pages correctly", () => {
|
|
expect(calculateTotalPages(0)).toBe(0);
|
|
expect(calculateTotalPages(1)).toBe(1);
|
|
expect(calculateTotalPages(8)).toBe(1);
|
|
expect(calculateTotalPages(9)).toBe(2);
|
|
expect(calculateTotalPages(16)).toBe(2);
|
|
expect(calculateTotalPages(17)).toBe(3);
|
|
});
|
|
|
|
it("uses custom page size", () => {
|
|
expect(calculateTotalPages(10, 5)).toBe(2);
|
|
expect(calculateTotalPages(11, 5)).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("large model lists (OpenRouter-scale)", () => {
|
|
it("handles 100+ models with pagination", () => {
|
|
const models = Array.from({ length: 150 }, (_, i) => `model-${i}`);
|
|
const totalPages = calculateTotalPages(models.length);
|
|
expect(totalPages).toBe(19); // 150 / 8 = 18.75 -> 19 pages
|
|
|
|
// Test first page
|
|
const firstPage = buildModelsKeyboard({
|
|
provider: "openrouter",
|
|
models,
|
|
currentPage: 1,
|
|
totalPages,
|
|
});
|
|
expect(firstPage.length).toBe(10); // 8 models + pagination + back
|
|
expect(firstPage[0]?.[0]?.text).toBe("model-0");
|
|
expect(firstPage[7]?.[0]?.text).toBe("model-7");
|
|
|
|
// Test last page
|
|
const lastPage = buildModelsKeyboard({
|
|
provider: "openrouter",
|
|
models,
|
|
currentPage: 19,
|
|
totalPages,
|
|
});
|
|
// Last page has 150 - (18 * 8) = 6 models
|
|
expect(lastPage.length).toBe(8); // 6 models + pagination + back
|
|
expect(lastPage[0]?.[0]?.text).toBe("model-144");
|
|
});
|
|
|
|
it("all callback_data stays within 64-byte limit", () => {
|
|
// Realistic OpenRouter model IDs
|
|
const models = [
|
|
"anthropic/claude-3-5-sonnet-20241022",
|
|
"google/gemini-2.0-flash-thinking-exp:free",
|
|
"deepseek/deepseek-r1-distill-llama-70b",
|
|
"meta-llama/llama-3.3-70b-instruct:nitro",
|
|
"nousresearch/hermes-3-llama-3.1-405b:extended",
|
|
];
|
|
const result = buildModelsKeyboard({
|
|
provider: "openrouter",
|
|
models,
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
|
|
for (const row of result) {
|
|
for (const button of row) {
|
|
const bytes = Buffer.byteLength(button.callback_data, "utf8");
|
|
expect(bytes).toBeLessThanOrEqual(64);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("skips models that would exceed callback_data limit", () => {
|
|
const models = [
|
|
"short-model",
|
|
"this-is-an-extremely-long-model-name-that-definitely-exceeds-the-sixty-four-byte-limit",
|
|
"another-short",
|
|
];
|
|
const result = buildModelsKeyboard({
|
|
provider: "openrouter",
|
|
models,
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
|
|
// Should have 2 model buttons (skipping the long one) + back
|
|
const modelButtons = result.filter((row) => !row[0]?.callback_data.startsWith("mdl_back"));
|
|
expect(modelButtons.length).toBe(2);
|
|
expect(modelButtons[0]?.[0]?.text).toBe("short-model");
|
|
expect(modelButtons[1]?.[0]?.text).toBe("another-short");
|
|
});
|
|
});
|