|
4 | 4 |
|
5 | 5 | import { describe, it, expect, vi, beforeEach } from "vitest" |
6 | 6 | import { modelCommand } from "../model.js" |
| 7 | +import { createMockContext } from "./helpers/mockContext.js" |
7 | 8 | import type { CommandContext } from "../core/types.js" |
8 | 9 | import type { RouterModels } from "../../types/messages.js" |
9 | 10 | import type { ProviderConfig } from "../../config/types.js" |
| 11 | +import type { ModelRecord } from "../../constants/providers/models.js" |
10 | 12 |
|
11 | 13 | describe("/model command", () => { |
12 | 14 | let mockContext: CommandContext |
@@ -51,26 +53,35 @@ describe("/model command", () => { |
51 | 53 | apiKey: "test-key", |
52 | 54 | } |
53 | 55 |
|
| 56 | + // Create many models for pagination testing |
| 57 | + const createManyModels = (count: number): ModelRecord => { |
| 58 | + const models: ModelRecord = {} |
| 59 | + for (let i = 1; i <= count; i++) { |
| 60 | + models[`model-${i}`] = { |
| 61 | + contextWindow: 100000 + i * 1000, |
| 62 | + supportsPromptCache: i % 2 === 0, |
| 63 | + supportsImages: i % 3 === 0, |
| 64 | + inputPrice: i * 0.5, |
| 65 | + outputPrice: i * 1.0, |
| 66 | + displayName: `Model ${i}`, |
| 67 | + } |
| 68 | + } |
| 69 | + return models |
| 70 | + } |
| 71 | + |
54 | 72 | beforeEach(() => { |
55 | 73 | addMessageMock = vi.fn() |
56 | 74 | updateProviderModelMock = vi.fn().mockResolvedValue(undefined) |
57 | 75 |
|
58 | | - mockContext = { |
| 76 | + mockContext = createMockContext({ |
59 | 77 | input: "/model", |
60 | 78 | args: [], |
61 | | - options: {}, |
62 | | - sendMessage: vi.fn().mockResolvedValue(undefined), |
63 | | - addMessage: addMessageMock, |
64 | | - clearMessages: vi.fn(), |
65 | | - clearTask: vi.fn().mockResolvedValue(undefined), |
66 | | - setMode: vi.fn(), |
67 | | - exit: vi.fn(), |
68 | 79 | routerModels: mockRouterModels, |
69 | 80 | currentProvider: mockProvider, |
70 | 81 | kilocodeDefaultModel: "", |
71 | 82 | updateProviderModel: updateProviderModelMock, |
72 | | - refreshRouterModels: vi.fn().mockResolvedValue(undefined), |
73 | | - } |
| 83 | + addMessage: addMessageMock, |
| 84 | + }) |
74 | 85 | }) |
75 | 86 |
|
76 | 87 | describe("Command metadata", () => { |
@@ -107,7 +118,7 @@ describe("/model command", () => { |
107 | 118 |
|
108 | 119 | it("should have arguments defined", () => { |
109 | 120 | expect(modelCommand.arguments).toBeDefined() |
110 | | - expect(modelCommand.arguments).toHaveLength(2) |
| 121 | + expect(modelCommand.arguments).toHaveLength(3) |
111 | 122 | }) |
112 | 123 |
|
113 | 124 | it("should have subcommand argument with values", () => { |
@@ -313,32 +324,47 @@ describe("/model command", () => { |
313 | 324 | }) |
314 | 325 |
|
315 | 326 | it("should filter models when filter is provided", async () => { |
| 327 | + // Mock updateModelListFilters to actually update the filters |
| 328 | + const updateFiltersMock = vi.fn((filters) => { |
| 329 | + mockContext.modelListFilters = { ...mockContext.modelListFilters, ...filters } |
| 330 | + }) |
| 331 | + mockContext.updateModelListFilters = updateFiltersMock |
316 | 332 | mockContext.args = ["list", "gpt-4"] |
317 | 333 |
|
318 | 334 | await modelCommand.handler(mockContext) |
319 | 335 |
|
| 336 | + // Verify the filter was persisted |
| 337 | + expect(updateFiltersMock).toHaveBeenCalledWith({ search: "gpt-4" }) |
| 338 | + |
320 | 339 | const message = addMessageMock.mock.calls[0][0] |
321 | | - expect(message.content).toContain("Filtered by") |
| 340 | + expect(message.content).toContain('Search: "gpt-4"') |
322 | 341 | expect(message.content).toContain("gpt-4") |
323 | 342 | expect(message.content).not.toContain("gpt-3.5-turbo") |
324 | 343 | }) |
325 | 344 |
|
326 | 345 | it("should show message when no models match filter", async () => { |
| 346 | + // Mock updateModelListFilters to actually update the filters |
| 347 | + const updateFiltersMock = vi.fn((filters) => { |
| 348 | + mockContext.modelListFilters = { ...mockContext.modelListFilters, ...filters } |
| 349 | + }) |
| 350 | + mockContext.updateModelListFilters = updateFiltersMock |
327 | 351 | mockContext.args = ["list", "nonexistent"] |
328 | 352 |
|
329 | 353 | await modelCommand.handler(mockContext) |
330 | 354 |
|
| 355 | + // Verify the filter was persisted |
| 356 | + expect(updateFiltersMock).toHaveBeenCalledWith({ search: "nonexistent" }) |
| 357 | + |
331 | 358 | const message = addMessageMock.mock.calls[0][0] |
332 | 359 | expect(message.type).toBe("system") |
333 | 360 | expect(message.content).toContain("No models found") |
334 | 361 | }) |
335 | 362 |
|
336 | | - it("should display model count", async () => { |
| 363 | + it("should display model count with pagination", async () => { |
337 | 364 | await modelCommand.handler(mockContext) |
338 | 365 |
|
339 | 366 | const message = addMessageMock.mock.calls[0][0] |
340 | | - expect(message.content).toContain("Total:") |
341 | | - expect(message.content).toContain("2 models") |
| 367 | + expect(message.content).toContain("Showing 1-2 of 2") |
342 | 368 | }) |
343 | 369 |
|
344 | 370 | it("should show error when no provider configured", async () => { |
@@ -429,4 +455,204 @@ describe("/model command", () => { |
429 | 455 | } |
430 | 456 | }) |
431 | 457 | }) |
| 458 | + |
| 459 | + describe("Model list pagination", () => { |
| 460 | + beforeEach(() => { |
| 461 | + mockContext.routerModels = { |
| 462 | + ...mockRouterModels, |
| 463 | + openrouter: createManyModels(25), |
| 464 | + } |
| 465 | + mockContext.args = ["list"] |
| 466 | + }) |
| 467 | + |
| 468 | + it("should paginate results with 10 items per page", async () => { |
| 469 | + await modelCommand.handler(mockContext) |
| 470 | + |
| 471 | + const message = addMessageMock.mock.calls[0][0] |
| 472 | + expect(message.content).toContain("Showing 1-10 of 25") |
| 473 | + expect(message.content).toContain("Page 1/3") |
| 474 | + }) |
| 475 | + |
| 476 | + it("should navigate to specific page", async () => { |
| 477 | + mockContext.args = ["list", "page", "2"] |
| 478 | + |
| 479 | + await modelCommand.handler(mockContext) |
| 480 | + |
| 481 | + expect(mockContext.changeModelListPage).toHaveBeenCalledWith(1) |
| 482 | + }) |
| 483 | + |
| 484 | + it("should go to next page", async () => { |
| 485 | + mockContext.args = ["list", "next"] |
| 486 | + mockContext.modelListPageIndex = 0 |
| 487 | + |
| 488 | + await modelCommand.handler(mockContext) |
| 489 | + |
| 490 | + expect(mockContext.changeModelListPage).toHaveBeenCalledWith(1) |
| 491 | + }) |
| 492 | + |
| 493 | + it("should go to previous page", async () => { |
| 494 | + mockContext.args = ["list", "prev"] |
| 495 | + mockContext.modelListPageIndex = 1 |
| 496 | + |
| 497 | + await modelCommand.handler(mockContext) |
| 498 | + |
| 499 | + expect(mockContext.changeModelListPage).toHaveBeenCalledWith(0) |
| 500 | + }) |
| 501 | + |
| 502 | + it("should show error when already on first page", async () => { |
| 503 | + mockContext.args = ["list", "prev"] |
| 504 | + mockContext.modelListPageIndex = 0 |
| 505 | + |
| 506 | + await modelCommand.handler(mockContext) |
| 507 | + |
| 508 | + const message = addMessageMock.mock.calls[0][0] |
| 509 | + expect(message.type).toBe("system") |
| 510 | + expect(message.content).toContain("Already on the first page") |
| 511 | + }) |
| 512 | + |
| 513 | + it("should show error when already on last page", async () => { |
| 514 | + mockContext.args = ["list", "next"] |
| 515 | + mockContext.modelListPageIndex = 2 |
| 516 | + |
| 517 | + await modelCommand.handler(mockContext) |
| 518 | + |
| 519 | + const message = addMessageMock.mock.calls[0][0] |
| 520 | + expect(message.type).toBe("system") |
| 521 | + expect(message.content).toContain("Already on the last page") |
| 522 | + }) |
| 523 | + |
| 524 | + it("should validate page number", async () => { |
| 525 | + mockContext.args = ["list", "page", "invalid"] |
| 526 | + |
| 527 | + await modelCommand.handler(mockContext) |
| 528 | + |
| 529 | + const message = addMessageMock.mock.calls[0][0] |
| 530 | + expect(message.type).toBe("error") |
| 531 | + expect(message.content).toContain("Invalid page number") |
| 532 | + }) |
| 533 | + |
| 534 | + it("should validate page number is within range", async () => { |
| 535 | + mockContext.args = ["list", "page", "10"] |
| 536 | + |
| 537 | + await modelCommand.handler(mockContext) |
| 538 | + |
| 539 | + const message = addMessageMock.mock.calls[0][0] |
| 540 | + expect(message.type).toBe("error") |
| 541 | + expect(message.content).toContain("Must be between 1 and") |
| 542 | + }) |
| 543 | + }) |
| 544 | + |
| 545 | + describe("Model list sorting", () => { |
| 546 | + beforeEach(() => { |
| 547 | + mockContext.args = ["list"] |
| 548 | + }) |
| 549 | + |
| 550 | + it("should sort by name", async () => { |
| 551 | + mockContext.args = ["list", "sort", "name"] |
| 552 | + |
| 553 | + await modelCommand.handler(mockContext) |
| 554 | + |
| 555 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "name" }) |
| 556 | + }) |
| 557 | + |
| 558 | + it("should sort by context window", async () => { |
| 559 | + mockContext.args = ["list", "sort", "context"] |
| 560 | + |
| 561 | + await modelCommand.handler(mockContext) |
| 562 | + |
| 563 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "context" }) |
| 564 | + }) |
| 565 | + |
| 566 | + it("should sort by price", async () => { |
| 567 | + mockContext.args = ["list", "sort", "price"] |
| 568 | + |
| 569 | + await modelCommand.handler(mockContext) |
| 570 | + |
| 571 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "price" }) |
| 572 | + }) |
| 573 | + |
| 574 | + it("should show error for invalid sort option", async () => { |
| 575 | + mockContext.args = ["list", "sort", "invalid"] |
| 576 | + |
| 577 | + await modelCommand.handler(mockContext) |
| 578 | + |
| 579 | + const message = addMessageMock.mock.calls[0][0] |
| 580 | + expect(message.type).toBe("error") |
| 581 | + expect(message.content).toContain("Invalid sort option") |
| 582 | + }) |
| 583 | + |
| 584 | + it("should show error when sort option is missing", async () => { |
| 585 | + mockContext.args = ["list", "sort"] |
| 586 | + |
| 587 | + await modelCommand.handler(mockContext) |
| 588 | + |
| 589 | + const message = addMessageMock.mock.calls[0][0] |
| 590 | + expect(message.type).toBe("error") |
| 591 | + expect(message.content).toContain("Usage: /model list sort") |
| 592 | + }) |
| 593 | + }) |
| 594 | + |
| 595 | + describe("Model list filtering", () => { |
| 596 | + beforeEach(() => { |
| 597 | + mockContext.args = ["list"] |
| 598 | + }) |
| 599 | + |
| 600 | + it("should filter by images capability", async () => { |
| 601 | + mockContext.args = ["list", "filter", "images"] |
| 602 | + |
| 603 | + await modelCommand.handler(mockContext) |
| 604 | + |
| 605 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ |
| 606 | + capabilities: ["images"], |
| 607 | + }) |
| 608 | + }) |
| 609 | + |
| 610 | + it("should filter by cache capability", async () => { |
| 611 | + mockContext.args = ["list", "filter", "cache"] |
| 612 | + |
| 613 | + await modelCommand.handler(mockContext) |
| 614 | + |
| 615 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ |
| 616 | + capabilities: ["cache"], |
| 617 | + }) |
| 618 | + }) |
| 619 | + |
| 620 | + it("should toggle filter off when already active", async () => { |
| 621 | + mockContext.args = ["list", "filter", "images"] |
| 622 | + mockContext.modelListFilters = { |
| 623 | + sort: "preferred", |
| 624 | + capabilities: ["images"], |
| 625 | + } |
| 626 | + |
| 627 | + await modelCommand.handler(mockContext) |
| 628 | + |
| 629 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ |
| 630 | + capabilities: [], |
| 631 | + }) |
| 632 | + }) |
| 633 | + |
| 634 | + it("should clear all filters", async () => { |
| 635 | + mockContext.args = ["list", "filter", "all"] |
| 636 | + mockContext.modelListFilters = { |
| 637 | + sort: "preferred", |
| 638 | + capabilities: ["images", "cache"], |
| 639 | + } |
| 640 | + |
| 641 | + await modelCommand.handler(mockContext) |
| 642 | + |
| 643 | + expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ |
| 644 | + capabilities: [], |
| 645 | + }) |
| 646 | + }) |
| 647 | + |
| 648 | + it("should show error for invalid filter option", async () => { |
| 649 | + mockContext.args = ["list", "filter", "invalid"] |
| 650 | + |
| 651 | + await modelCommand.handler(mockContext) |
| 652 | + |
| 653 | + const message = addMessageMock.mock.calls[0][0] |
| 654 | + expect(message.type).toBe("error") |
| 655 | + expect(message.content).toContain("Invalid filter option") |
| 656 | + }) |
| 657 | + }) |
432 | 658 | }) |
0 commit comments