/** @vitest-environment jsdom */ import { describe, it, expect, vi } from 'vitest'; import { CARD_SIZES, cardLayout, fitPhoto, drawShareCard, stampAiGeneratedWatermark, fitText, wrapTwoLines, drawSpacedText, type CardRect } from './shareCard'; import { context } from './fixtures/canvasContext'; const photo = { naturalWidth: 1600, naturalHeight: 900 } as HTMLImageElement; const metadata = { itemName: 'Linen Overshirt', brandName: 'Atelier Nord', productUrl: 'https://forbidden.test' }; const inside = (inner: CardRect, outer: CardRect) => { expect(inner.x).toBeGreaterThanOrEqual(outer.x - 1e-8); expect(inner.y).toBeGreaterThanOrEqual(outer.y - 1e-8); expect(inner.x + inner.width).toBeLessThanOrEqual(outer.x + outer.width + 1e-8); expect(inner.y + inner.height).toBeLessThanOrEqual(outer.y + outer.height + 1e-8); }; const intersection = (a: CardRect, b: CardRect) => Math.max(0, Math.min(a.x+a.width,b.x+b.width)-Math.max(a.x,b.x)) * Math.max(0, Math.min(a.y+a.height,b.y+b.height)-Math.max(a.y,b.y)); describe('Polaroid geometry and actual canvas operations', () => { for (const size of [...Object.values(CARD_SIZES), { width: 540, height: 960 }, { width: 1080, height: 1080 }, { width: 1920, height: 1080 }]) { for (const [iw, ih] of [[1080,1920], [602,1024], [1600,900], [900,900], [100,4000], [4000,100]]) { it(`${size.width}x${size.height}: whole ${iw}x${ih} image, separate UI, final disclosure`, () => { const ctx = context(size.width, size.height); const image = { naturalWidth: iw, naturalHeight: ih } as HTMLImageElement; const layout = cardLayout(size), canvas = { x: 0, y: 0, ...size }; const ui = [layout.caption, ...layout.chips, layout.tag, layout.strip, layout.watermark]; for (const r of [layout.photo, ...ui]) inside(r, canvas); for (const r of ui) expect(intersection(r, layout.photo)).toBe(0); for (let i = 0; i < ui.length; i++) for (let j = i+1; j < ui.length; j++) expect(intersection(ui[i], ui[j])).toBe(0); drawShareCard(ctx, image, null, metadata); stampAiGeneratedWatermark(ctx); // Inspect the actual draw, not just the layout helper: five arguments mean // the entire source is used (the nine-argument overload can crop it). const draw = vi.mocked(ctx.drawImage).mock.calls[0]; expect(draw).toHaveLength(5); expect(draw[0]).toBe(image); const [, x, y, w, h] = draw as unknown as [HTMLImageElement, number, number, number, number]; inside({ x, y, width: w, height: h }, layout.photo); expect(w / iw).toBeCloseTo(h / ih, 10); expect(w / iw).toBeLessThanOrEqual(Math.min(layout.photo.width / iw, layout.photo.height / ih) + 1e-8); expect({ x, y, width: w, height: h }).toEqual(fitPhoto(image, layout.photo)); expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, size.width, size.height); expect(ctx.roundRect).toHaveBeenCalledWith(layout.strip.x,layout.strip.y,layout.strip.width,layout.strip.height,28*layout.scale); const texts = vi.mocked(ctx.fillText).mock.calls; expect(texts.find(c=>c[0]==='Should I get it?')?.slice(1)).toEqual([layout.caption.x,layout.caption.y]); const words = texts.map(c=>c[0]); for (const word of ['Should I get it?','❤️','yes','😢','no','powered by','aisthetix','Try it on your own photo','ai generated']) expect(words).toContain(word); expect(words.join(' ')).not.toMatch(/https:|forbidden|created with|virtual try-on|variant|QR|\$/); expect(texts.at(-1)?.[0]).toBe('ai generated'); expect(texts.at(-1)?.[1]).toBeCloseTo(size.width - 24*layout.scale, 8); expect(texts.at(-1)?.[2]).toBeCloseTo(size.height - 16*layout.scale, 8); const lastDraw = Math.max(...['drawImage','fillRect','fill','stroke','fillText','restore'].flatMap(k=>vi.mocked((ctx as any)[k]).mock.invocationCallOrder)); expect(vi.mocked(ctx.fillText).mock.invocationCallOrder.at(-1)).toBe(lastDraw); }); } } it('fits long identity into two lines and uses the product thumbnail', () => { const ctx=context(); const thumb={...photo} as HTMLImageElement; drawShareCard(ctx,photo,thumb,{...metadata,itemName:'A very long linen overshirt with many extra words in its name',brandName:'Extremely long merchant brand name'}); const texts=vi.mocked(ctx.fillText).mock.calls; expect(texts.find(c=>c[0].startsWith('EXTREMELY'))?.[0]).toMatch(/…$/); const names=texts.filter(c=>c[1]===168 && !c[0].startsWith('EXTREMELY')); expect(names).toHaveLength(2); expect(names[1][0]).toMatch(/…$/); expect(ctx.drawImage).toHaveBeenCalledTimes(2); expect(vi.mocked(ctx.drawImage).mock.calls[1][0]).toBe(thumb); }); it('omits empty brand/item and draws a placeholder when no thumbnail loads', () => { const ctx=context(); drawShareCard(ctx,photo,null,{itemName:'Shirt',brandName:' ',productUrl:null}); expect(vi.mocked(ctx.fillText).mock.calls.filter(c=>c[1]===168)).toHaveLength(1); expect(ctx.lineTo).toHaveBeenCalled(); const empty=context(); drawShareCard(empty,photo,null,{itemName:' ',brandName:'BRAND',productUrl:null}); expect(vi.mocked(empty.fillText).mock.calls.some(c=>c[0]==='BRAND')).toBe(false); expect(empty.lineTo).not.toHaveBeenCalled(); }); it('rejects invalid canvases and fits images with width/height only', () => { for (const size of [{width:0,height:1920},{width:1080,height:-1},{width:NaN,height:1920},{width:1080,height:Infinity}]) expect(()=>cardLayout(size)).toThrow('invalid canvas'); expect(fitPhoto({width:100,height:200} as HTMLImageElement,{x:0,y:0,width:100,height:100})).toEqual({x:25,y:0,width:50,height:100}); }); it('fits one/two lines, ellipsis, hard words, and spaces glyphs without letterSpacing', () => { const ctx=context(); expect(fitText(ctx,'Short',304)).toBe('Short'); expect(wrapTwoLines(ctx,'one two',120)).toEqual(['one two']); expect(wrapTwoLines(ctx,'one two three',120)).toEqual(['one two','three']); expect(wrapTwoLines(ctx,'one two three four five',120)).toEqual(['one two','three…']); expect(wrapTwoLines(ctx,'abcdefghijklmnopqrstuvwxyz',120)).toEqual(['abcdef…']); delete (ctx as any).letterSpacing; drawSpacedText(ctx,'AB',706,1020,3.6); expect(ctx.fillText).toHaveBeenCalledWith('A',706,1020);expect(ctx.fillText).toHaveBeenCalledWith('B',726.6,1020); expect(()=>drawShareCard(ctx,{width:0,height:0} as HTMLImageElement,null,{itemName:'',productUrl:null})).toThrow('invalid image dimensions'); }); });