diff --git a/src/client/src/components/ChatView.image.test.ts b/src/client/src/components/ChatView.image.test.ts new file mode 100644 index 0000000..50929f7 --- /dev/null +++ b/src/client/src/components/ChatView.image.test.ts @@ -0,0 +1,79 @@ +import type { TemplateResult } from "lit"; +import { describe, expect, it } from "vitest"; +import type { ChatLine } from "./shared"; +import { ChatView } from "./ChatView"; + +describe("ChatView image rendering", () => { + // Direct handler extraction keeps this node-environment test focused on the + // late image-load scroll wiring without introducing a component-wide DOM shim. + it("renders native image data and re-pins late loads only while already pinned", () => { + const view = new ChatView(); + let scrollCalls = 0; + if (!Reflect.set(view, "scrollToBottom", () => { scrollCalls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom"); + const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" }); + const onLoad = templateEventHandler(rendered, "@load="); + + expect(templateStaticMarkup(rendered)).toContain(" TemplateResult; +type TemplateEventHandler = (event: Event) => void; + +function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult { + const method: unknown = Reflect.get(view, "renderPart"); + if (!isRenderPart(method)) throw new Error("ChatView.renderPart is not callable"); + return method.call(view, part, message); +} + +function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { + const strings = templateStrings(template); + const values = templateValues(template); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value; + } + throw new Error(`Expected template event handler after ${marker}`); +} + +function isRenderPart(value: unknown): value is RenderPart { + return typeof value === "function"; +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +function templateStaticMarkup(template: TemplateResult): string { + return templateStrings(template).join(""); +} + +function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] { + const strings = templateStrings(template); + return templateValues(template).filter((_, index) => strings[index]?.includes(marker) === true); +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index d3206f5..df3b167 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -38,29 +38,6 @@ describe("chatMessageMetadataLabel", () => { }); }); -describe("ChatView image rendering", () => { - // Direct handler extraction keeps this node-environment test focused on the - // late image-load scroll wiring without introducing a component-wide DOM shim. - it("renders native image data and re-pins late loads only while already pinned", () => { - const view = new ChatView(); - let scrollCalls = 0; - if (!Reflect.set(view, "scrollToBottom", () => { scrollCalls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom"); - const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" }); - const onLoad = templateEventHandler(rendered, "@load="); - - expect(templateStaticMarkup(rendered)).toContain(" { const messages: ChatLine[] = [ { role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] }, @@ -126,17 +103,10 @@ interface GroupBodyRenderCall { startIndex: number; } -type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult; type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult; type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult; type TemplateEventHandler = (event: Event) => void; -function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult { - const method: unknown = Reflect.get(view, "renderPart"); - if (!isRenderPart(method)) throw new Error("ChatView.renderPart is not callable"); - return method.call(view, part, message); -} - function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult { const method: unknown = Reflect.get(view, "renderMessageGroup"); if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable"); @@ -155,10 +125,6 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] { return calls; } -function isRenderPart(value: unknown): value is RenderPart { - return typeof value === "function"; -} - function isRenderMessageGroup(value: unknown): value is RenderMessageGroup { return typeof value === "function"; }