import { create, type MessageInitShape } from "@bufbuild/protobuf"; import { toBlob } from "html-to-image"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildMemoShareImageFileName, createMemoShareImageBlob, getMemoShareDialogWidth, getMemoSharePreviewAvatarUrl, getMemoSharePreviewWidth, getMemoShareRenderWidth, } from "@/components/MemoActionMenu/memoShareImage"; import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel"; import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; vi.mock("html-to-image", () => ({ toBlob: vi.fn(), })); const buildMemo = (overrides: MessageInitShape = {}) => create(MemoSchema, { name: "memos/test", content: "hello", tags: [], attachments: [], ...overrides, }); const buildAttachment = (overrides: MessageInitShape) => create(AttachmentSchema, { name: "attachments/test", filename: "test.bin", type: "application/octet-stream", ...overrides, }); const buildPreviewModel = (memo: Memo) => buildMemoShareImagePreviewModel({ memo, fallbackDisplayName: "Memo", locale: "en-US", }); describe("memo share image preview model", () => { it("does not create footer chips for memo tags already rendered in content", () => { const memo = buildMemo({ content: "Investigate #bug", tags: ["bug"], }); const model = buildPreviewModel(memo); expect(model.footerBadges).toEqual([]); }); it("keeps non-visual attachments visible as a footer summary", () => { const memo = buildMemo({ attachments: [ buildAttachment({ name: "attachments/doc", filename: "doc.pdf", type: "application/pdf", }), ], }); const model = buildPreviewModel(memo); expect(model.visualItems).toEqual([]); expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 1 }]); }); it("keeps visual attachments in the media grid without adding a footer summary", () => { const memo = buildMemo({ attachments: [ buildAttachment({ name: "attachments/image", filename: "image.png", type: "image/png", }), ], }); const model = buildPreviewModel(memo); expect(model.visualItems).toHaveLength(1); expect(model.visualItems[0]?.posterUrl).toContain("/file/attachments/image/image.png?thumbnail=true"); expect(model.footerBadges).toEqual([]); }); it("does not repeat a managed inline image in the media grid", () => { const memo = buildMemo({ content: "![image](/file/attachments/image)", attachments: [buildAttachment({ name: "attachments/image", filename: "image.png", type: "image/png" })], }); const model = buildPreviewModel(memo); expect(model.visualItems).toEqual([]); expect(model.footerBadges).toEqual([]); }); it("counts mixed visual and non-visual attachments in the summary", () => { const memo = buildMemo({ attachments: [ buildAttachment({ name: "attachments/image", filename: "image.png", type: "image/png", }), buildAttachment({ name: "attachments/archive", filename: "archive.zip", type: "application/zip", }), ], }); const model = buildPreviewModel(memo); expect(model.visualItems).toHaveLength(1); expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 2 }]); }); }); describe("memo share image utilities", () => { beforeEach(() => { vi.mocked(toBlob).mockResolvedValue(new Blob(["preview"], { type: "image/png" })); }); it("builds filenames from memo resource names", () => { expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png"); expect(buildMemoShareImageFileName("")).toBe("memo-memo.png"); }); it("clamps preview and dialog widths", () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 }); expect(getMemoSharePreviewWidth(100)).toBe(260); expect(getMemoSharePreviewWidth(800)).toBe(520); expect(getMemoShareDialogWidth(520)).toBe(600); expect(getMemoShareRenderWidth(520, 600)).toBe(560); }); it("uses the viewport when no card width is available", () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 }); expect(getMemoSharePreviewWidth(0)).toBe(317); }); it("keeps only exportable avatar URLs", () => { expect(getMemoSharePreviewAvatarUrl("/avatars/a.png")).toBe("/avatars/a.png"); expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc"); expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined(); }); it("promotes lazy images and stops waiting after the asset timeout", async () => { vi.useFakeTimers(); try { const preview = document.createElement("div"); const image = document.createElement("img"); image.loading = "lazy"; Object.defineProperty(image, "complete", { configurable: true, value: false }); preview.appendChild(image); const renderPromise = createMemoShareImageBlob(preview); await vi.advanceTimersByTimeAsync(0); expect(image.loading).toBe("eager"); expect(toBlob).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(5000); await expect(renderPromise).resolves.toBeInstanceOf(Blob); expect(toBlob).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); } }); });