fix: harden week-two ordering, artwork and operations

This commit is contained in:
Cauê Faleiros
2026-09-23 10:40:18 -03:00
parent ccc25a2d5d
commit 24013458c9
43 changed files with 1064 additions and 228 deletions

View File

@@ -13,6 +13,30 @@ const html=await readFile('web/index.html','utf8');
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
function pdfFixture({pages=1,media='[0 0 720 360]',crop='',rotate=0,unit=1}={}){
const objects=[
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids ['+Array.from({length:pages},(_,i)=>i+3+' 0 R').join(' ')+
'] /Count '+pages+' /MediaBox '+media+' >>',
...Array.from({length:pages},()=> '<< /Type /Page /Parent 2 0 R'+
(crop?' /CropBox '+crop:'')+(rotate?' /Rotate '+rotate:'')+
(unit!==1?' /UserUnit '+unit:'')+' >>')
];
const chunks=['%PDF-1.6\n%\xE2\xE3\xCF\xD3\n'], offsets=[0];
let length=Buffer.byteLength(chunks[0],'latin1');
for(let i=0;i<objects.length;i++){
offsets.push(length);
const part=(i+1)+' 0 obj\n'+objects[i]+'\nendobj\n';
chunks.push(part);length+=Buffer.byteLength(part,'latin1');
}
const xref=length;
chunks.push('xref\n0 '+(objects.length+1)+'\n0000000000 65535 f \n');
for(const offset of offsets.slice(1))chunks.push(String(offset).padStart(10,'0')+' 00000 n \n');
chunks.push('trailer\n<< /Size '+(objects.length+1)+
' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF\n');
return Buffer.from(chunks.join(''),'latin1');
}
const pdfData=options=>JSON.stringify(pdfFixture(options).toString('base64'));
const server=createServer(async(req,res)=>{
if(req.url.startsWith('/api/')){
res.setHeader('Content-Type','application/json');
@@ -27,7 +51,7 @@ const server=createServer(async(req,res)=>{
// Serve any script the page asks for, resolved inside web/, rather than
// a hardcoded list: the Site's behaviour is split across several files and a
// list would silently 404 the next one added.
if(/^\/[\w.-]+\.js$/.test(req.url)){
if(/^\/[\w.-]+\.js$/.test(req.url) || /^\/vendor\/pdf\.(worker\.)?min\.js$/.test(req.url)){
try{
const body=await readFile(resolve('web'+req.url));
res.setHeader('Content-Type','text/javascript');res.end(body);return;
@@ -39,6 +63,7 @@ await new Promise(r=>server.listen(0,'127.0.0.1',r));
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
let stderr='',ws,next=0;
@@ -128,6 +153,9 @@ try{
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
{shown:true,on:['folha']});
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:128*1048576+1});sel([f]);})()`);
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('128 MB')`),true,
'files beyond the scanner limit are rejected before browser analysis');
await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa');
await evaluate('sendImage()');
@@ -135,6 +163,14 @@ try{
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
await fill('[data-cm]',20);await fill('[data-q]',6);
let layout=await packed(6);
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.copies===6`),'per-file production record');
assert.deepEqual(await evaluate(`({version:itemAtual.production.version,source:itemAtual.production.sources[0]})`),
{version:2,source:{kind:'artwork',width_cm:20,length_cm:40,copies:6,
rotation_degrees:0,mirrored:false,measurement:'file'}});
assert.equal(await evaluate('itemAtual.production.placements.length'),6);
assert.equal(await evaluate('itemAtual.production.height_cm'),121);
assert.deepEqual(await evaluate('itemAtual.production.placements.map(p=>[p.source_index,p.copy_index,p.x_cm,p.y_cm])'),
[[0,0,0,0],[0,1,20.5,0],[0,2,0,40.5],[0,3,20.5,40.5],[0,4,0,81],[0,5,20.5,81]]);
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
assert.equal(layout.billed,1.3);
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
@@ -149,6 +185,32 @@ try{
await click('[data-esp]');
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
assert.equal(await evaluate('metros'),0.235);
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.mirrored===true`),'transforms in production record');
assert.deepEqual(await evaluate(`({rotation:itemAtual.production.sources[0].rotation_degrees,copies:itemAtual.production.sources[0].copies})`),
{rotation:90,copies:9});
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='rejected'`),'low resolution refusal');
assert.equal(await evaluate('cartPodeEnviar()'),false);
await fill('[data-cm]',3);
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='ok'`),'rotated DPI uses image height');
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),339);
await click('[data-giro]');
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'unrotated resolution warning');
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),169);
assert.equal(await evaluate('cartPodeEnviar()'),false);
await click('#cienteOk');
assert.equal(await evaluate('cartPodeEnviar()'),true);
await fill('[data-q]',8);
assert.equal(await evaluate('itemAtual===null'),true,'editing invalidates the old cart immediately');
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'edited warning');
assert.equal(await evaluate('cartPodeEnviar()'),false,'acknowledgement does not survive an edit');
await fill('[data-cm]',58);
await waitFor(()=>evaluate(`itemAtual===null && artes[0].cm===58`),'oversized width rejected');
assert.equal(await evaluate('cartPodeEnviar()'),false);
assert.equal(await evaluate(`(()=>{try{encaixar([{w:58,h:10,img:null}],57);return false}catch(error){return error instanceof RangeError}})()`),true,
'packing engine must not shrink an oversized source');
await click('[data-rm]');
assert.equal(await evaluate(`artes.length===0 && itemAtual===null && !cartPodeEnviar()`),true,
'removed artwork cannot remain in the cart');
// A transparent asymmetric image exposes masks that ignore user transforms.
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
assert.equal(await evaluate(`(()=>{
@@ -169,6 +231,8 @@ try{
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
await evaluate(`(()=>{folhas.push({f:new File(['manual'],'manual.cdr'),med:null,rep:1,m:1,an:null});pintaFolha();})()`);
await waitFor(()=>evaluate(`itemAtual?.nota===0 && itemAtual?.unit===TABELA[modo]`),'mixed analyzed and manual sheets use table pricing');
// An image too small to span the film is refused, never silently repriced.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate('sendImage()');
@@ -182,6 +246,33 @@ try{
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
await evaluate(`folhas[0].semAnalise='<img src=x onerror=alert(1)>';pintaFolha()`);
assert.equal(await evaluate(`!$('lista').querySelector('img') && $('lista').textContent.includes('<img src=x')`),true,
'PDF parser errors are text, never executable markup');
// Parsed geometry honors inherited MediaBox, CropBox, rotation and UserUnit.
// Extra pages and unreadable PDFs must never fall through to manual pricing.
const pdfFile=(data,name='sheet.pdf')=>`new File([Uint8Array.from(atob(${data}),c=>c.charCodeAt(0))],${JSON.stringify(name)},{type:'application/pdf'})`;
const rotated=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))})`);
assert.deepEqual(rotated,{larg:50.8,alt:25.4,fonte:'página do PDF · UserUnit 2'});
const rendered=await evaluate(`rasterizarPdf(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))},50.8,25.4).then(r=>({ok:!!r.tela,error:r.erro||null}))`);
assert.deepEqual(rendered,{ok:true,error:null});
const multi=await evaluate(`medirFolha(${pdfFile(pdfData({pages:2}))})`);
assert.equal(multi.rejected,true);assert.match(multi.reason,/2 páginas/);
const beyondSpec=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 20000 720]'}))})`);
assert.equal(beyondSpec.rejected,true);assert.match(beyondSpec.reason,/508 cm/);
const broken=await evaluate(`medirFolha(new File(['broken'],'broken.pdf',{type:'application/pdf'}))`);
assert.equal(broken.rejected,true);
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([${pdfFile(pdfData({pages:2}),'two-pages.pdf')}])`);
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].measurementError`),'multipage PDF refusal');
assert.equal(await evaluate(`avaliar().pronto`),false);
assert.equal(await evaluate(`cartPodeEnviar()`),false);
assert.match(await evaluate(`$('lista').textContent`),/não pode ser orçado/);
await click('#bVoltar');await click('[data-modo="avulsa"]');
await evaluate(`sel([new File(['not an image'],'broken.png',{type:'image/png'})])`);
await waitFor(()=>evaluate('artes.length===1 && artes[0].decodeError===true'),'failed image decode');
await fill('[data-cm]',20);
await waitFor(()=>evaluate('itemAtual===null && !cartPodeEnviar()'),'undecodable image cannot be quoted');
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
// from a by-metre product is one declared click, and it is reversible.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
@@ -201,6 +292,21 @@ try{
await fill('[data-q]',7);
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
await packed(7);
const timeoutCleanup=await evaluate(`(async()=>{
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;
let destroyed=false, fail;
carregarPdfJs=async()=>({getDocument:()=>({
promise:new Promise((_,reject)=>{fail=reject}),
destroy:()=>{destroyed=true;fail(new Error('cancelled'));return Promise.resolve()}
})});
temWorker=true;
window.setTimeout=(fn,ms)=>realTimer(fn,ms===30000?1:ms);
try{
const result=await rasterizarPdf({arrayBuffer:async()=>new ArrayBuffer(1)},10,10);
return {timedOut:result.erro==='demorou demais neste navegador',destroyed};
}finally{carregarPdfJs=realLoader;window.setTimeout=realTimer;temWorker=realWorker;}
})()`);
assert.deepEqual(timeoutCleanup,{timedOut:true,destroyed:true});
// Inspect the supplied local artwork, when requested, using the real file input.
if(process.env.ARTWORK_FILE){
await click('#bVoltar');await click('[data-modo="file"]');