-
Notifications
You must be signed in to change notification settings - Fork 1
π§ͺ [Test] Add unit tests for core TUI UI components #490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,143 @@ | ||||||||||
| import { describe, expect, it, vi, beforeEach } from "vitest"; | ||||||||||
| import { AssistantMessageComponent } from "./assistant-message.js"; | ||||||||||
|
|
||||||||||
| let markdownInstances: any[] = []; | ||||||||||
| let imageInstances: any[] = []; | ||||||||||
|
|
||||||||||
| vi.mock("@mariozechner/pi-tui", () => { | ||||||||||
| return { | ||||||||||
| Markdown: class Markdown { | ||||||||||
| constructor(public text: string, top: number, left: number, theme: any, options: any) { | ||||||||||
| markdownInstances.push(this); | ||||||||||
| } | ||||||||||
| setText = vi.fn((text) => { this.text = text; }); | ||||||||||
| render = vi.fn().mockImplementation((w) => this.text ? [this.text] : []); | ||||||||||
| invalidate = vi.fn(); | ||||||||||
| }, | ||||||||||
| Image: class Image { | ||||||||||
| constructor(base64: string, mimeType: string, theme: any, options: any) { | ||||||||||
| imageInstances.push(this); | ||||||||||
| } | ||||||||||
| render = vi.fn().mockReturnValue(["[Image]"]); | ||||||||||
| invalidate = vi.fn(); | ||||||||||
| } | ||||||||||
| }; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| vi.mock("../theme.js", () => { | ||||||||||
| return { | ||||||||||
| miladyMarkdownTheme: { mockTheme: true }, | ||||||||||
| tuiTheme: { | ||||||||||
| muted: vi.fn((t) => `muted:${t}`), | ||||||||||
| dim: vi.fn((t) => `dim:${t}`) | ||||||||||
| } | ||||||||||
| }; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| describe("AssistantMessageComponent", () => { | ||||||||||
| beforeEach(() => { | ||||||||||
| vi.clearAllMocks(); | ||||||||||
| markdownInstances = []; | ||||||||||
| imageInstances = []; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("initializes empty and streaming", () => { | ||||||||||
| const component = new AssistantMessageComponent(); | ||||||||||
| expect(component).toBeDefined(); | ||||||||||
|
|
||||||||||
| // Empty output initially | ||||||||||
| const rendered = component.render(100); | ||||||||||
| expect(rendered).toEqual([]); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("updates content and renders with streaming cursor", () => { | ||||||||||
| const component = new AssistantMessageComponent(); | ||||||||||
|
|
||||||||||
| component.updateContent("Hello"); | ||||||||||
|
|
||||||||||
| // The main markdown should be instantiated on construction | ||||||||||
| const md = markdownInstances[0]; | ||||||||||
| expect(md.setText).toHaveBeenCalledWith("Hello β"); | ||||||||||
|
|
||||||||||
| const rendered = component.render(100); | ||||||||||
| expect(rendered).toEqual(["", "Hello β"]); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("finalizes and removes streaming cursor", () => { | ||||||||||
| const component = new AssistantMessageComponent(); | ||||||||||
|
|
||||||||||
| component.updateContent("Hello"); | ||||||||||
| component.finalize(); | ||||||||||
|
|
||||||||||
| const md = markdownInstances[0]; | ||||||||||
| expect(md.setText).toHaveBeenCalledWith("Hello"); | ||||||||||
|
|
||||||||||
| const rendered = component.render(100); | ||||||||||
| expect(rendered).toEqual(["", "Hello"]); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("shows thinking text when enabled", () => { | ||||||||||
| const component = new AssistantMessageComponent(true); | ||||||||||
|
|
||||||||||
| // updateThinking triggers a rebuild which creates a new Markdown instance | ||||||||||
| component.updateThinking("Hmm..."); | ||||||||||
| component.updateContent("Yes"); | ||||||||||
| component.finalize(); | ||||||||||
|
|
||||||||||
| // We expect multiple instances due to rebuilds, but we care about the render output | ||||||||||
| const rendered = component.render(100); | ||||||||||
| expect(rendered).toEqual([ | ||||||||||
| "", | ||||||||||
| "Hmm...", | ||||||||||
| "", | ||||||||||
| "Yes" | ||||||||||
| ]); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("ignores thinking text when disabled", () => { | ||||||||||
| const component = new AssistantMessageComponent(false); | ||||||||||
|
|
||||||||||
| component.updateThinking("Hmm..."); | ||||||||||
| component.updateContent("Yes"); | ||||||||||
| component.finalize(); | ||||||||||
|
|
||||||||||
| const rendered = component.render(100); | ||||||||||
| expect(rendered).toEqual(["", "Yes"]); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("adds and renders images", () => { | ||||||||||
| const component = new AssistantMessageComponent(); | ||||||||||
|
|
||||||||||
| component.updateContent("Look at this"); | ||||||||||
| component.finalize(); | ||||||||||
|
|
||||||||||
| component.addImage({ | ||||||||||
| base64: "dGVzdA==", | ||||||||||
| mimeType: "image/png", | ||||||||||
| filename: "test.png" | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| expect(imageInstances.length).toBe(1); | ||||||||||
|
|
||||||||||
| const rendered = component.render(100); | ||||||||||
| expect(rendered).toEqual([ | ||||||||||
| "", | ||||||||||
| "Look at this", | ||||||||||
| "", // spacer | ||||||||||
| "[Image]" | ||||||||||
| ]); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| it("invalidates all sub-components", () => { | ||||||||||
| const component = new AssistantMessageComponent(true); | ||||||||||
| component.updateThinking("Hmm"); | ||||||||||
| component.addImage({ base64: "dGVzdA==", mimeType: "image/png" }); | ||||||||||
|
|
||||||||||
| component.invalidate(); | ||||||||||
|
|
||||||||||
| // Check that at least some invalidates were called | ||||||||||
| const someMarkdownInvalidated = markdownInstances.some(md => md.invalidate.mock.calls.length > 0); | ||||||||||
| expect(someMarkdownInvalidated).toBe(true); | ||||||||||
|
Comment on lines
+139
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The assertion using
Suggested change
|
||||||||||
| expect(imageInstances[0].invalidate).toHaveBeenCalled(); | ||||||||||
| }); | ||||||||||
|
Comment on lines
+131
to
+142
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The invalidation test only checks that at least one markdown instance is invalidated ( |
||||||||||
| }); | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,181 @@ | ||||||||||||||||
| import { describe, expect, it, vi, beforeEach } from "vitest"; | ||||||||||||||||
| import { ToolExecutionComponent } from "./tool-execution.js"; | ||||||||||||||||
|
|
||||||||||||||||
| // Mock tuiTheme first so we can verify color fns | ||||||||||||||||
| vi.mock("../theme.js", () => { | ||||||||||||||||
| return { | ||||||||||||||||
| tuiTheme: { | ||||||||||||||||
| warning: vi.fn((s) => `warning:${s}`), | ||||||||||||||||
| muted: vi.fn((s) => `muted:${s}`), | ||||||||||||||||
| toolPendingBg: vi.fn((s) => `toolPendingBg:${s}`), | ||||||||||||||||
| toolSuccessBg: vi.fn((s) => `toolSuccessBg:${s}`), | ||||||||||||||||
| toolErrorBg: vi.fn((s) => `toolErrorBg:${s}`), | ||||||||||||||||
| bold: vi.fn((s) => `bold:${s}`), | ||||||||||||||||
| error: vi.fn((s) => `error:${s}`), | ||||||||||||||||
| dim: vi.fn((s) => `dim:${s}`), | ||||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| // We'll capture added children to verify logic | ||||||||||||||||
| let boxChildren: any[] = []; | ||||||||||||||||
| let boxBgFn: Function | null = null; | ||||||||||||||||
| let loaderInstance: any = null; | ||||||||||||||||
|
Comment on lines
+21
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding a
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| vi.mock("@mariozechner/pi-tui", () => { | ||||||||||||||||
| return { | ||||||||||||||||
| TUI: class TUI {}, | ||||||||||||||||
| Box: class Box { | ||||||||||||||||
| constructor() { | ||||||||||||||||
| boxChildren = []; | ||||||||||||||||
| boxBgFn = null; | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+29
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||
| addChild = vi.fn((child) => { boxChildren.push(child); }); | ||||||||||||||||
| clear = vi.fn(() => { boxChildren = []; }); | ||||||||||||||||
| setBgFn = vi.fn((fn) => { boxBgFn = fn; }); | ||||||||||||||||
| render = vi.fn().mockReturnValue(["rendered box"]); | ||||||||||||||||
| invalidate = vi.fn(); | ||||||||||||||||
| }, | ||||||||||||||||
| Text: class Text { | ||||||||||||||||
| constructor(public text: string, public x: number, public y: number) {} | ||||||||||||||||
| }, | ||||||||||||||||
| Loader: class Loader { | ||||||||||||||||
| constructor() { | ||||||||||||||||
| loaderInstance = this; | ||||||||||||||||
| } | ||||||||||||||||
| stop = vi.fn(); | ||||||||||||||||
| invalidate = vi.fn(); | ||||||||||||||||
| }, | ||||||||||||||||
| }; | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| describe("ToolExecutionComponent", () => { | ||||||||||||||||
| let tui: any; | ||||||||||||||||
|
|
||||||||||||||||
| beforeEach(async () => { | ||||||||||||||||
| vi.clearAllMocks(); | ||||||||||||||||
| boxChildren = []; | ||||||||||||||||
| boxBgFn = null; | ||||||||||||||||
| loaderInstance = null; | ||||||||||||||||
|
Comment on lines
+58
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||
| const { TUI } = await import("@mariozechner/pi-tui"); | ||||||||||||||||
| tui = new TUI(); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("initializes in running state with loader and pending bg", () => { | ||||||||||||||||
| const args = { arg1: "value1" }; | ||||||||||||||||
| const component = new ToolExecutionComponent("testTool", args, tui); | ||||||||||||||||
|
|
||||||||||||||||
| expect(component).toBeDefined(); | ||||||||||||||||
|
|
||||||||||||||||
| // Header text with arg summary | ||||||||||||||||
| expect(boxChildren.length).toBe(2); | ||||||||||||||||
| expect(boxChildren[0].text).toContain("bold:testTool"); | ||||||||||||||||
| expect(boxChildren[0].text).toContain("muted:arg1=value1"); | ||||||||||||||||
|
|
||||||||||||||||
| // Loader should be added | ||||||||||||||||
| expect(boxChildren[1]).toBe(loaderInstance); | ||||||||||||||||
|
|
||||||||||||||||
| // Check render | ||||||||||||||||
| expect(component.render(100)).toEqual(["rendered box"]); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("summarizes long arguments correctly and clips total summary length", () => { | ||||||||||||||||
| const args = { | ||||||||||||||||
| shortStr: "hello", | ||||||||||||||||
| longStr: "this is a very long string that should be truncated because it exceeds thirty characters", | ||||||||||||||||
| num: 42, | ||||||||||||||||
| obj: { key: "val" } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| new ToolExecutionComponent("testTool", args, tui); | ||||||||||||||||
|
|
||||||||||||||||
| const headerText = boxChildren[0].text; | ||||||||||||||||
| expect(headerText).toContain("bold:testTool"); | ||||||||||||||||
|
|
||||||||||||||||
| // "longStr" value should be truncated to 27 chars + "..." (30 chars) | ||||||||||||||||
| expect(headerText).toContain("this is a very long string ..."); | ||||||||||||||||
| expect(headerText).not.toContain("exceeds thirty characters"); | ||||||||||||||||
|
|
||||||||||||||||
| // other args should be present if they fit | ||||||||||||||||
| expect(headerText).toContain("shortStr=hello"); | ||||||||||||||||
| expect(headerText).toContain("num=42"); | ||||||||||||||||
| expect(headerText).toContain('obj={"key":"val"}'); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("updates result with success", () => { | ||||||||||||||||
| const component = new ToolExecutionComponent("testTool", {}, tui); | ||||||||||||||||
|
|
||||||||||||||||
| component.updateResult({ text: "Success output", isError: false }); | ||||||||||||||||
|
|
||||||||||||||||
| // Loader should be stopped | ||||||||||||||||
| expect(loaderInstance.stop).toHaveBeenCalled(); | ||||||||||||||||
|
|
||||||||||||||||
| // Box bg should be updated to success | ||||||||||||||||
| expect(boxBgFn).toBeDefined(); | ||||||||||||||||
| if (boxBgFn) expect(boxBgFn("test")).toBe("toolSuccessBg:test"); | ||||||||||||||||
|
|
||||||||||||||||
| // Output text should be added | ||||||||||||||||
| expect(boxChildren.length).toBe(2); // Header + Output | ||||||||||||||||
| expect(boxChildren[1].text).toContain("muted:Success output"); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("updates result with error", () => { | ||||||||||||||||
| const component = new ToolExecutionComponent("testTool", {}, tui); | ||||||||||||||||
|
|
||||||||||||||||
| component.updateResult({ text: "Error output", isError: true }); | ||||||||||||||||
|
|
||||||||||||||||
| // Loader should be stopped | ||||||||||||||||
| expect(loaderInstance.stop).toHaveBeenCalled(); | ||||||||||||||||
|
|
||||||||||||||||
| // Box bg should be updated to error | ||||||||||||||||
| expect(boxBgFn).toBeDefined(); | ||||||||||||||||
| if (boxBgFn) expect(boxBgFn("test")).toBe("toolErrorBg:test"); | ||||||||||||||||
|
|
||||||||||||||||
| // Output text should be added with error color | ||||||||||||||||
| expect(boxChildren.length).toBe(2); // Header + Output | ||||||||||||||||
| expect(boxChildren[1].text).toContain("error:Error output"); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("collapses long output to 5 lines by default", () => { | ||||||||||||||||
| const component = new ToolExecutionComponent("testTool", {}, tui); | ||||||||||||||||
|
|
||||||||||||||||
| const longOutput = Array.from({length: 10}, (_, i) => `Line ${i+1}`).join("\n"); | ||||||||||||||||
| component.updateResult({ text: longOutput, isError: false }); | ||||||||||||||||
|
|
||||||||||||||||
| // Should have header, 5 lines of output, and the "more lines" indicator | ||||||||||||||||
| expect(boxChildren.length).toBe(3); | ||||||||||||||||
|
|
||||||||||||||||
| const outputText = boxChildren[1].text; | ||||||||||||||||
| expect(outputText).toContain("Line 1"); | ||||||||||||||||
| expect(outputText).toContain("Line 5"); | ||||||||||||||||
| expect(outputText).not.toContain("Line 6"); | ||||||||||||||||
|
|
||||||||||||||||
| expect(boxChildren[2].text).toContain("dim:... (5 more lines, Ctrl+E to expand)"); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("expands long output when requested", () => { | ||||||||||||||||
| const component = new ToolExecutionComponent("testTool", {}, tui); | ||||||||||||||||
|
|
||||||||||||||||
| const longOutput = Array.from({length: 10}, (_, i) => `Line ${i+1}`).join("\n"); | ||||||||||||||||
| component.updateResult({ text: longOutput, isError: false }); | ||||||||||||||||
|
|
||||||||||||||||
| component.setExpanded(true); | ||||||||||||||||
|
|
||||||||||||||||
| // Should have header, and all lines of output | ||||||||||||||||
| expect(boxChildren.length).toBe(2); | ||||||||||||||||
|
|
||||||||||||||||
| const outputText = boxChildren[1].text; | ||||||||||||||||
| expect(outputText).toContain("Line 1"); | ||||||||||||||||
| expect(outputText).toContain("Line 10"); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it("invalidates correctly", () => { | ||||||||||||||||
| const component = new ToolExecutionComponent("testTool", {}, tui); | ||||||||||||||||
| // Grab the mock instance from our mock setup | ||||||||||||||||
| const { Box } = require("@mariozechner/pi-tui"); | ||||||||||||||||
|
Comment on lines
+174
to
+175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||
|
|
||||||||||||||||
| component.invalidate(); | ||||||||||||||||
|
|
||||||||||||||||
| expect(loaderInstance.invalidate).toHaveBeenCalled(); | ||||||||||||||||
| }); | ||||||||||||||||
| }); | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { describe, expect, it, vi, beforeEach } from "vitest"; | ||
| import { UserMessageComponent } from "./user-message.js"; | ||
|
|
||
| let markdownText = ""; | ||
| let markdownThemeRef = null; | ||
| let markdownOptions = null; | ||
|
|
||
| vi.mock("@mariozechner/pi-tui", () => { | ||
| return { | ||
| Markdown: class Markdown { | ||
| constructor(text, top, left, theme, options) { | ||
| markdownText = text; | ||
| markdownThemeRef = theme; | ||
| markdownOptions = options; | ||
| } | ||
| render = vi.fn().mockReturnValue(["md line 1", "md line 2"]); | ||
| invalidate = vi.fn(); | ||
| } | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock("../theme.js", () => { | ||
| return { | ||
| miladyMarkdownTheme: { mockTheme: true }, | ||
| tuiTheme: { | ||
| userMsgBg: vi.fn((t) => `bg:${t}`) | ||
| } | ||
| }; | ||
| }); | ||
|
|
||
| describe("UserMessageComponent", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| markdownText = ""; | ||
| markdownThemeRef = null; | ||
| markdownOptions = null; | ||
| }); | ||
|
|
||
| it("initializes with text and default theme", () => { | ||
| const text = "Hello world"; | ||
| const component = new UserMessageComponent(text); | ||
|
|
||
| expect(component).toBeDefined(); | ||
| expect(markdownText).toBe(text); | ||
| expect(markdownThemeRef).toEqual({ mockTheme: true }); | ||
|
|
||
| // Check bgColor mapping | ||
| expect(markdownOptions).toBeDefined(); | ||
| expect(markdownOptions.bgColor).toBeDefined(); | ||
| expect(markdownOptions.bgColor("test")).toBe("bg:test"); | ||
| }); | ||
|
|
||
| it("renders with blank line prepended", () => { | ||
| const component = new UserMessageComponent("test"); | ||
| const rendered = component.render(100); | ||
|
|
||
| expect(rendered.length).toBe(3); | ||
| expect(rendered[0]).toBe(""); | ||
| expect(rendered[1]).toBe("md line 1"); | ||
| expect(rendered[2]).toBe("md line 2"); | ||
| }); | ||
|
|
||
| it("invalidates correctly", () => { | ||
| const component = new UserMessageComponent("test"); | ||
|
|
||
| // Get the markdown instance | ||
| const mdInstance = (component as any).markdown; | ||
|
|
||
| component.invalidate(); | ||
|
|
||
| expect(mdInstance.invalidate).toHaveBeenCalled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test for image addition does not verify the component's behavior when invalid or incomplete image data is provided. This could mask bugs in error handling logic. Consider adding test cases that pass malformed or missing fields in the image object to ensure the component handles such cases gracefully and does not throw uncaught exceptions.