Archived
refactor(test): migrate ChatView image test to shared inspection seam
Replace the per-file TemplateResult inspection helper cluster in ChatView.image.test.ts with the shared templateInspection.testSupport escape hatch for genuine event wiring (@load re-pin, @click zoom), and move content/attribute assertions to new pure public seams on ChatView (chatImagePartSource, chatToolOutputLabel, chatMessageAnchorKey) that the component's own render code now delegates to.
This commit is contained in:
@@ -1,22 +1,49 @@
|
|||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ChatLine } from "./shared";
|
import type { ChatLine } from "./shared";
|
||||||
import { ChatView, chatMessageMetadataLabel } from "./ChatView";
|
import {
|
||||||
|
ChatView,
|
||||||
|
chatImagePartSource,
|
||||||
|
chatMessageAnchorKey,
|
||||||
|
chatToolOutputLabel,
|
||||||
|
} from "./ChatView";
|
||||||
|
import { templateEventHandlerAfterMarker } from "../templateInspection.testSupport";
|
||||||
|
|
||||||
describe("ChatView image rendering", () => {
|
describe("ChatView image content derivation", () => {
|
||||||
// Direct handler extraction keeps this node-environment test focused on the
|
// Content/attribute derivation (image src/alt, the tool-output header label,
|
||||||
// late image-load scroll wiring without introducing a component-wide DOM shim.
|
// and the scroll-anchor key) lives in pure exported seams rather than being
|
||||||
it("renders native image data and re-pins late loads only while already pinned", () => {
|
// scraped from rendered `TemplateResult` markup, per the testing-guide rule
|
||||||
|
// that TemplateResult inspection is not for general content assertions.
|
||||||
|
it("derives the image data URL and alt text from an image part", () => {
|
||||||
|
expect(chatImagePartSource({ type: "image", mimeType: "image/png", data: "QUJD" })).toEqual({
|
||||||
|
src: "data:image/png;base64,QUJD",
|
||||||
|
alt: "attached image",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels tool image output by tool name and falls back to a generic label", () => {
|
||||||
|
expect(chatToolOutputLabel("read")).toBe("read output");
|
||||||
|
expect(chatToolOutputLabel(undefined)).toBe("tool output");
|
||||||
|
expect(chatToolOutputLabel("")).toBe("tool output");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys a tool image message to its stable scroll anchor", () => {
|
||||||
|
expect(chatMessageAnchorKey(7)).toBe("m:7");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatView image event wiring", () => {
|
||||||
|
// Escape hatch: these two cases verify Lit event wiring (`@load` re-pin and
|
||||||
|
// `@click` zoom) whose only observable effect is a private state/scroll side
|
||||||
|
// effect. Vitest runs with no DOM environment here, so a shadow-DOM click
|
||||||
|
// harness would add disproportionate setup; direct handler extraction anchored
|
||||||
|
// to the stable `@load=`/`@click=` attribute markup is proportionate.
|
||||||
|
it("re-pins late image loads only while already pinned to the bottom", () => {
|
||||||
const view = new ChatView();
|
const view = new ChatView();
|
||||||
let scrollCalls = 0;
|
let scrollCalls = 0;
|
||||||
if (!Reflect.set(view, "scrollToBottom", () => { scrollCalls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
|
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 rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" });
|
||||||
const onLoad = templateEventHandler(rendered, "@load=");
|
const onLoad = templateEventHandlerAfterMarker(rendered, "@load=");
|
||||||
|
|
||||||
expect(templateStaticMarkup(rendered)).toContain("<img");
|
|
||||||
expect(templateStaticMarkup(rendered)).toContain('loading="lazy"');
|
|
||||||
expect(templateStaticMarkup(rendered)).toContain('role="button"');
|
|
||||||
expect(templateValuesAfterMarker(rendered, "src=")).toEqual(["data:image/png;base64,QUJD"]);
|
|
||||||
|
|
||||||
if (!Reflect.set(view, "pinnedToBottom", true)) throw new Error("Could not set ChatView.pinnedToBottom");
|
if (!Reflect.set(view, "pinnedToBottom", true)) throw new Error("Could not set ChatView.pinnedToBottom");
|
||||||
onLoad(new Event("load"));
|
onLoad(new Event("load"));
|
||||||
@@ -26,41 +53,21 @@ describe("ChatView image rendering", () => {
|
|||||||
expect(scrollCalls).toBe(1);
|
expect(scrollCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicking an image records the zoom target so the modal dialog can present
|
|
||||||
// it at full size, and dismissing clears the target.
|
|
||||||
it("opens and closes the image zoom target on click and close", () => {
|
it("opens and closes the image zoom target on click and close", () => {
|
||||||
const view = new ChatView();
|
const view = new ChatView();
|
||||||
const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" });
|
const part = { type: "image", mimeType: "image/png", data: "QUJD" } as const;
|
||||||
const onClick = templateEventHandler(rendered, "@click=");
|
const rendered = renderPart(view, part);
|
||||||
|
const onClick = templateEventHandlerAfterMarker(rendered, "@click=");
|
||||||
|
|
||||||
expect(zoomedImage(view)).toBeUndefined();
|
expect(zoomedImage(view)).toBeUndefined();
|
||||||
onClick(new Event("click"));
|
onClick(new Event("click"));
|
||||||
expect(zoomedImage(view)).toEqual({ src: "data:image/png;base64,QUJD", alt: "attached image" });
|
expect(zoomedImage(view)).toEqual(chatImagePartSource(part));
|
||||||
|
|
||||||
const close: unknown = Reflect.get(view, "closeImageZoom");
|
const close: unknown = Reflect.get(view, "closeImageZoom");
|
||||||
if (typeof close !== "function") throw new Error("ChatView.closeImageZoom is not callable");
|
if (typeof close !== "function") throw new Error("ChatView.closeImageZoom is not callable");
|
||||||
close.call(view);
|
close.call(view);
|
||||||
expect(zoomedImage(view)).toBeUndefined();
|
expect(zoomedImage(view)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Direct rendering keeps this node-environment test focused on the dedicated
|
|
||||||
// tool-image presentation without introducing a component-wide DOM shim.
|
|
||||||
it("renders tool images as labeled standard messages with final metadata", () => {
|
|
||||||
const message: ChatLine = {
|
|
||||||
role: "tool",
|
|
||||||
parts: [{ type: "image", mimeType: "image/png", data: "QUJD" }],
|
|
||||||
meta: { timestamp: "2026-07-13T22:00:00.000Z" },
|
|
||||||
};
|
|
||||||
const rendered = renderToolImageOutput(new ChatView(), message, 7, "read");
|
|
||||||
const markup = templateStaticMarkup(rendered);
|
|
||||||
|
|
||||||
expect(markup).toContain('class="msg tool-image-output"');
|
|
||||||
expect(markup).not.toContain('class="msg tool"');
|
|
||||||
expect(markup).toContain("<img");
|
|
||||||
expect(templateValuesAfterMarker(rendered, '<b class="label">')).toEqual(["read output"]);
|
|
||||||
expect(templateValuesAfterMarker(rendered, "title=").filter((value) => typeof value === "string")).toEqual([chatMessageMetadataLabel(message)]);
|
|
||||||
expect(templateValuesAfterMarker(rendered, "data-scroll-anchor-id=")).toEqual(["m:7"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function zoomedImage(view: ChatView): unknown {
|
function zoomedImage(view: ChatView): unknown {
|
||||||
@@ -68,8 +75,6 @@ function zoomedImage(view: ChatView): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult;
|
type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult;
|
||||||
type RenderToolImageOutput = (this: ChatView, message: ChatLine, index: number, toolName?: string) => TemplateResult;
|
|
||||||
type TemplateEventHandler = (event: Event) => void;
|
|
||||||
|
|
||||||
function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult {
|
function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult {
|
||||||
const method: unknown = Reflect.get(view, "renderPart");
|
const method: unknown = Reflect.get(view, "renderPart");
|
||||||
@@ -77,86 +82,6 @@ function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: C
|
|||||||
return method.call(view, part, message);
|
return method.call(view, part, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolImageOutput(view: ChatView, message: ChatLine, index: number, toolName?: string): TemplateResult {
|
|
||||||
const method: unknown = Reflect.get(view, "renderToolImageOutput");
|
|
||||||
if (!isRenderToolImageOutput(method)) throw new Error("ChatView.renderToolImageOutput is not callable");
|
|
||||||
return method.call(view, message, index, toolName);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
function isRenderPart(value: unknown): value is RenderPart {
|
||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRenderToolImageOutput(value: unknown): value is RenderToolImageOutput {
|
|
||||||
return typeof value === "function";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
|
|
||||||
return typeof value === "function";
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateStaticMarkup(template: TemplateResult): string {
|
|
||||||
const chunks: string[] = [];
|
|
||||||
visit(template);
|
|
||||||
return chunks.join("");
|
|
||||||
|
|
||||||
function visit(value: unknown): void {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) visit(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isTemplateResult(value)) return;
|
|
||||||
chunks.push(...templateStrings(value));
|
|
||||||
for (const child of templateValues(value)) visit(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] {
|
|
||||||
const matches: unknown[] = [];
|
|
||||||
visit(template);
|
|
||||||
return matches;
|
|
||||||
|
|
||||||
function visit(value: unknown): void {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) visit(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isTemplateResult(value)) return;
|
|
||||||
const strings = templateStrings(value);
|
|
||||||
const values = templateValues(value);
|
|
||||||
for (let index = 0; index < values.length; index += 1) {
|
|
||||||
if (strings[index]?.includes(marker) === true) matches.push(values[index]);
|
|
||||||
visit(values[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 isTemplateResult(value: unknown): value is TemplateResult {
|
|
||||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStringArray(value: unknown): value is string[] {
|
|
||||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[],
|
|||||||
].filter((section): section is QueuedMessageSection => section !== undefined);
|
].filter((section): section is QueuedMessageSection => section !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChatImagePart = Extract<ChatPart, { type: "image" }>;
|
||||||
|
|
||||||
|
/** Derive the `<img>` source URL and alt text for a rendered image part. */
|
||||||
|
export function chatImagePartSource(part: ChatImagePart): { src: string; alt: string } {
|
||||||
|
return { src: `data:${part.mimeType};base64,${part.data}`, alt: "attached image" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message-header label used when a tool message renders as an image output. */
|
||||||
|
export function chatToolOutputLabel(toolName?: string): string {
|
||||||
|
return toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stable scroll-anchor/render key for a top-level message at `index`. */
|
||||||
|
export function chatMessageAnchorKey(index: number): string {
|
||||||
|
return `m:${String(index)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function chatMessageMetadataLabel(message: ChatLine): string {
|
export function chatMessageMetadataLabel(message: ChatLine): string {
|
||||||
const timestamp = message.meta?.timestamp;
|
const timestamp = message.meta?.timestamp;
|
||||||
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
||||||
@@ -436,7 +453,7 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderToolImageOutput(message: ChatLine, index: number, toolName?: string) {
|
private renderToolImageOutput(message: ChatLine, index: number, toolName?: string) {
|
||||||
const label = toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`;
|
const label = chatToolOutputLabel(toolName);
|
||||||
return html`
|
return html`
|
||||||
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
|
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
|
||||||
<article class="msg tool-image-output" data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
|
<article class="msg tool-image-output" data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
|
||||||
@@ -570,8 +587,7 @@ export class ChatView extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (part.type === "image") {
|
if (part.type === "image") {
|
||||||
const src = `data:${part.mimeType};base64,${part.data}`;
|
const { src, alt } = chatImagePartSource(part);
|
||||||
const alt = "attached image";
|
|
||||||
return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`;
|
return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`;
|
||||||
}
|
}
|
||||||
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
|
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
|
||||||
@@ -874,7 +890,7 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private messageAnchorKey(index: number): string {
|
private messageAnchorKey(index: number): string {
|
||||||
return `m:${String(index)}`;
|
return chatMessageAnchorKey(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
private groupRenderKey(startIndex: number): string {
|
private groupRenderKey(startIndex: number): string {
|
||||||
@@ -890,7 +906,7 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private messageScrollMarkerId(index: number): string {
|
private messageScrollMarkerId(index: number): string {
|
||||||
return `m:${String(index)}`;
|
return chatMessageAnchorKey(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
private groupScrollMarkerId(endIndex: number): string {
|
private groupScrollMarkerId(endIndex: number): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user