summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-18 18:39:00 +0900
committerAdam Malczewski <[email protected]>2026-09-18 18:39:00 +0900
commit72663945ef0a0053fbfd83b168e90f12169a5c3a (patch)
treed4a7c0071c2a9baeba657316d59b7212031363e0 /src/features/chat/ui.test.ts
parent9c201c60a727106f1e3bf8c2af5f86aa7427098f (diff)
downloaddispatch-web-72663945ef0a0053fbfd83b168e90f12169a5c3a.tar.gz
dispatch-web-72663945ef0a0053fbfd83b168e90f12169a5c3a.zip
feat(chat): compress images at attach time (JPEG re-encode + rescale) with a per-image toggle
Staged images now default to a compressed variant: LOSSLESS sources (PNG/BMP) are re-encoded to JPEG at q0.85, rescaled to cap the longest edge at 2048px (never upscaled, high-quality smoothing, alpha flattened onto white), and used only under a smaller-wins rule. JPEG/WebP/GIF are sent verbatim. Each staged thumbnail gets a Compress checkmark (default ON, reversible until send, cached so flipping never re-encodes); OFF sends the original untouched bytes. Conversion runs async at staging and any failure silently falls back to the original — sending is never blocked or broken. Pure decision logic lives in features/chat/logic/image-compress.ts (format selection, dimension math, smaller-wins); the canvas encode is a thin injected edge (features/chat/adapter/image-encode.ts, Composer prop encodeJpeg). No wire-contract, send-path, or backend changes; complements the backend's body-budget eviction safety net by shrinking payloads at the source. Verification: typecheck 0 errors, vitest 1720/1720 (twice), biome clean, build succeeds. New tests: 12 pure-logic, 2 encoder-edge, 6 component.
Diffstat (limited to 'src/features/chat/ui.test.ts')
-rw-r--r--src/features/chat/ui.test.ts122
1 files changed, 122 insertions, 0 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 3a47219..9568813 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
import type { TurnMetricsEntry } from "../../core/metrics";
+import type { ConvertedJpeg } from "./adapter/image-encode";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
@@ -989,6 +990,127 @@ describe("Composer", () => {
});
});
+describe("Composer image compression", () => {
+ /** Stage a PNG through the file picker and wait for the thumbnail. */
+ async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> {
+ const file = new File([content], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+ }
+
+ /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */
+ function fakeJpeg(): ConvertedJpeg {
+ return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" };
+ }
+
+ it("shows the compress toggle CHECKED by default for a staged PNG", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ await stagePng(container);
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ expect(toggle).toBeChecked();
+ });
+
+ it("hides the compress toggle for already-lossy formats (JPEG)", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+
+ expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument();
+ });
+
+ it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+
+ // The async conversion lands and the staged URL reflects what will be sent.
+ await vi.waitFor(() => {
+ expect(encodeJpeg).toHaveBeenCalledTimes(1);
+ });
+ await vi.waitFor(() => {
+ const thumb = container.querySelector("img[alt='image/jpeg']");
+ expect(thumb).not.toBeNull();
+ expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images).toHaveLength(1);
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("checkbox", { name: "Compress image" }));
+ expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked();
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("re-checking uses the CACHED conversion — no second encode", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ await user.click(toggle); // OFF → original
+ await user.click(toggle); // ON again → must reuse the cached JPEG
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("falls back to the original image when conversion fails", async () => {
+ const encodeJpeg = vi.fn(async () => {
+ throw new Error("decode exploded");
+ });
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ });
+});
+
describe("ModelSelector", () => {
const optionValues = (el: HTMLElement): string[] =>
within(el)