diff options
| author | Adam Malczewski <[email protected]> | 2026-09-18 18:39:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-18 18:39:00 +0900 |
| commit | 72663945ef0a0053fbfd83b168e90f12169a5c3a (patch) | |
| tree | d4a7c0071c2a9baeba657316d59b7212031363e0 /src/features/chat/ui.test.ts | |
| parent | 9c201c60a727106f1e3bf8c2af5f86aa7427098f (diff) | |
| download | dispatch-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.ts | 122 |
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) |
