import assert from 'node:assert/strict'; import {test} from 'node:test'; import {loadTypescript} from './support/load-typescript.mjs'; const load=()=>loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url)); function png(width,height){const b=new Uint8Array(45),v=new DataView(b.buffer);b.set([137,80,78,71,13,10,26,10]);v.setUint32(8,13);b.set(new TextEncoder().encode('IHDR'),12);v.setUint32(16,width);v.setUint32(20,height);b.set(new TextEncoder().encode('IEND'),37);return new Blob([b],{type:'image/png'});} const withBitmap=async(fn,body)=>{const old=globalThis.createImageBitmap;globalThis.createImageBitmap=fn;try{await body();}finally{globalThis.createImageBitmap=old;}}; test('ordinary PNG requests bounded first decode, keeps aspect and no upscale',async()=>{await withBitmap(async(_f,o)=>({width:o.resizeWidth??8000,height:o.resizeHeight??6000,close(){}}),async()=>{const{createPhotoSnapshot}=await load();const s=await createPhotoSnapshot(png(8000,6000),1,new AbortController().signal);assert.deepEqual([s.width,s.height],[1024,768]);const small=await createPhotoSnapshot(png(320,240),2,new AbortController().signal);assert.deepEqual([small.width,small.height],[320,240]);});}); test('oversize dimensions are rejected before native decode',async()=>{let calls=0;await withBitmap(async()=>{calls++;return{width:1024,height:1024,close(){}};},async()=>{const{createPhotoSnapshot}=await load();await assert.rejects(createPhotoSnapshot(png(100000,100000),1,new AbortController().signal),/크|픽셀|64/);assert.equal(calls,0);});}); test('cancelled decode closes its result and cannot become a working photo',async()=>{let release,closed=0;await withBitmap(()=>new Promise(r=>{release=r;}),async()=>{const{createPhotoSnapshot}=await load(),abort=new AbortController(),pending=createPhotoSnapshot(png(4000,3000),1,abort.signal);while(!release)await new Promise(r=>setImmediate(r));abort.abort();release({width:1024,height:768,close(){closed++;}});await assert.rejects(pending,{name:'AbortError'});assert.equal(closed,1);});}); test('late resize cancellation releases both native bitmaps',async()=>{let release,calls=0,closed=0;await withBitmap(async()=>{if(++calls===1)return{width:4000,height:3000,close(){closed++;}};return new Promise(r=>{release=r;});},async()=>{const{createPhotoSnapshot}=await load(),abort=new AbortController(),pending=createPhotoSnapshot(png(4000,3000),1,abort.signal);while(!release)await new Promise(r=>setImmediate(r));abort.abort();release({width:1024,height:768,close(){closed++;}});await assert.rejects(pending,{name:'AbortError'});assert.equal(closed,2);});}); function imageHarness({abortOnBlob=false,decodeError=false}={}){const originals={Image:globalThis.Image,document:globalThis.document,createImageBitmap:globalThis.createImageBitmap,create:URL.createObjectURL,revoke:URL.revokeObjectURL};const abort=new AbortController(),urls=[],revoked=[],canvases=[],images=[];let closes=0;globalThis.createImageBitmap=async(source,options)=>({width:options?.resizeWidth??source.width,height:options?.resizeHeight??source.height,close(){closes++;}});globalThis.Image=class{constructor(){images.push(this);}set src(value){this._src=value;if(value)queueMicrotask(()=>decodeError?this.onerror?.():this.onload?.());}get src(){return this._src;}removeAttribute(){this._src='';}};globalThis.document={createElement(){const c={width:0,height:0,draws:[],getContext(){return{drawImage:(...args)=>c.draws.push(args)};},toBlob(callback,type){c.mime=type;if(abortOnBlob)abort.abort();callback(new Blob(['working-png'],{type}));}};canvases.push(c);return c;}};URL.createObjectURL=blob=>{urls.push(blob);return'blob:bounded'+urls.length;};URL.revokeObjectURL=url=>revoked.push(url);return{abort,urls,revoked,canvases,images,get closes(){return closes;},restore(){globalThis.Image=originals.Image;globalThis.document=originals.document;globalThis.createImageBitmap=originals.createImageBitmap;URL.createObjectURL=originals.create;URL.revokeObjectURL=originals.revoke;}};} test('lossless DOM adapter encodes only normalized RGB, retains alpha format and releases on dispose',async()=>{const h=imageHarness();try{const {createWorkingPhotoImage}=await loadTypescript(new URL('../src/input/WorkingPhotoImage.ts',import.meta.url));const p=await createWorkingPhotoImage(png(8000,6000),1,h.abort.signal);assert.equal(h.canvases[0].mime,'image/png');assert.deepEqual([h.canvases[0].draws[0][0].width,h.canvases[0].draws[0][0].height],[1024,768]);assert.equal(h.urls[0].type,'image/png');assert.equal(h.closes,1);assert.equal(h.canvases[0].width,1);assert.equal(h.revoked.length,0);p.dispose();p.dispose();assert.equal(h.revoked.length,1);assert.equal(p.image.src,'');}finally{h.restore();}}); test('cancel during PNG encode releases working bitmap/canvas without creating a URL',async()=>{const h=imageHarness({abortOnBlob:true});try{const {createWorkingPhotoImage}=await loadTypescript(new URL('../src/input/WorkingPhotoImage.ts',import.meta.url));await assert.rejects(createWorkingPhotoImage(png(4000,3000),1,h.abort.signal),{name:'AbortError'});assert.equal(h.closes,1);assert.equal(h.canvases[0].width,1);assert.equal(h.urls.length,0);}finally{h.restore();}}); test('failed preview decode revokes its URL and bitmap, never returns half-ready RGB',async()=>{const h=imageHarness({decodeError:true});try{const {createWorkingPhotoImage}=await loadTypescript(new URL('../src/input/WorkingPhotoImage.ts',import.meta.url));await assert.rejects(createWorkingPhotoImage(png(4000,3000),1,h.abort.signal),/작업용 사진/);assert.equal(h.closes,1);assert.equal(h.revoked.length,1);}finally{h.restore();}}); test('browser Blob-resize rejection falls back to a bounded alpha canvas and revokes source URL',async()=>{const h=imageHarness();let calls=0;globalThis.createImageBitmap=async(source,options)=>{calls++;if(source instanceof Blob)throw new Error('resize unsupported');assert.deepEqual([source.width,source.height],[1024,768]);return{width:source.width,height:source.height,close(){}};};globalThis.Image=class{naturalWidth=8000;naturalHeight=6000;set src(_value){queueMicrotask(()=>this.onload?.());}removeAttribute(){}};try{const {createPhotoSnapshot}=await load();const result=await createPhotoSnapshot(png(8000,6000),1,h.abort.signal);assert.deepEqual([result.width,result.height],[1024,768]);assert.equal(calls,2);assert.equal(h.revoked.length,1);assert.equal(h.canvases[0].width,1);}finally{h.restore();}}); test('unknown-format decoded dimensions still get the source guard and release',async()=>{let closed=0;await withBitmap(async()=>({width:100000,height:100000,close(){closed++;}}),async()=>{const{createPhotoSnapshot}=await load();await assert.rejects(createPhotoSnapshot(new Blob(['unknown-format']),1,new AbortController().signal),/사진이 너무 커요/);assert.equal(closed,1);});}); test('unsupported browser codec produces useful error and releases fallback object URL',async()=>{const h=imageHarness({decodeError:true});globalThis.createImageBitmap=async()=>{throw new Error('unsupported');};try{const{createPhotoSnapshot}=await load();await assert.rejects(createPhotoSnapshot(new Blob(['fake-heic'],{type:'image/heic'}),1,h.abort.signal),/HEIC.*JPEG.*PNG/);assert.equal(h.revoked.length,1);}finally{h.restore();}}); test('abort during fallback image decode revokes source URL without waiting for onload',async()=>{const h=imageHarness();let image;globalThis.createImageBitmap=async()=>{throw new Error('resize unsupported');};globalThis.Image=class{constructor(){image=this;}set src(v){this._src=v;}removeAttribute(){this._src='';}};try{const{createPhotoSnapshot}=await load(),pending=createPhotoSnapshot(png(4000,3000),1,h.abort.signal);while(!image)await new Promise(r=>setImmediate(r));h.abort.abort();await assert.rejects(pending,{name:'AbortError'});assert.equal(h.revoked.length,1);assert.equal(image._src,'');assert.equal(image.onload,null);}finally{h.restore();}}); test('browser silently ignoring bitmap resize options uses bounded canvas fallback',async()=>{const h=imageHarness();let closes=0,calls=0;globalThis.createImageBitmap=async(source)=>{calls++;const canvas=!(source instanceof Blob)&&source.getContext;return{width:canvas?source.width:4000,height:canvas?source.height:3000,close(){closes++;}};};try{const{createPhotoSnapshot}=await load();const result=await createPhotoSnapshot(png(4000,3000),1,h.abort.signal);assert.deepEqual([result.width,result.height],[1024,768]);assert.equal(calls,3);assert.equal(closes,2);result.bitmap.close();assert.equal(closes,3);}finally{h.restore();}}); // Retained original-photo coverage previously colocated with the retired cutout provider. const tick=async()=>{for(let i=0;i<8;i++)await Promise.resolve();}; test('photo snapshot applies EXIF once then constrains canonical longest edge',async()=>{const original=globalThis.createImageBitmap;const calls=[];let closed=0;globalThis.createImageBitmap=async(source,options)=>{calls.push(options);return calls.length===1?{width:1600,height:800,close(){closed++;}}:{width:1024,height:512,close(){closed++;}};};try{const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const result=await createPhotoSnapshot({},2,new AbortController().signal);assert.equal(result.width,1024);assert.equal(result.height,512);assert.equal(calls[0].imageOrientation,'from-image');assert.equal(calls[1].resizeWidth,1024);assert.equal(closed,1);}finally{globalThis.createImageBitmap=original;}}); test('snapshot abort during resize closes decoded and resized bitmaps exactly once',async()=>{const old=globalThis.createImageBitmap;let resolveResize,decodedClosed=0,resizedClosed=0;let count=0;globalThis.createImageBitmap=async()=>{if(++count===1)return{width:1600,height:800,close(){decodedClosed++;}};return new Promise(resolve=>{resolveResize=resolve;});};try{const {createPhotoSnapshot}=await loadTypescript(new URL('../src/input/PhotoSnapshot.ts',import.meta.url));const abort=new AbortController(),task=createPhotoSnapshot({},2,abort.signal),rejected=assert.rejects(task,{name:'AbortError'});await tick();abort.abort();resolveResize({width:1024,height:512,close(){resizedClosed++;}});await rejected;assert.equal(decodedClosed,1);assert.equal(resizedClosed,1);}finally{globalThis.createImageBitmap=old;}});