caterium-app/tests/proposal-quality.spec.mjs

226 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {test,expect} from '@playwright/test';
import {demo} from '../ops/demo/trial-demo-data.mjs';
test('a stalled photo cannot block the actual offer, and refresh recovers the missing image',async({page})=>{
test.setTimeout(35000);await boot(page);
await page.evaluate(d=>{
boxes=structuredClone(d.boxes.slice(0,6));orders=[{id:'slow-photo',event:'Проверка загрузки',lines:boxes.map(b=>({id:b.id,qty:1})),total:14900,status:'Новый',clientOfferTemplateId:'event-ticket'}];
const fetchOriginal=window.fetch;window.hangPhoto=true;window.photoAborted=false;
window.fetch=(input,init)=>{
if(window.hangPhoto&&String(input.url||input).includes('bruschetta-tomato.webp'))return new Promise((_,reject)=>init?.signal?.addEventListener('abort',()=>{window.photoAborted=true;reject(init.signal.reason)},{once:true}));
return fetchOriginal(input,init);
};
void window.sunOpenClientOffer('slow-photo');
},demo);
await expect(page.locator('#sunClientOfferPreview canvas').first()).toBeVisible({timeout:14000});
await expect(page.locator('#sunOfferPdf')).toBeEnabled();await expect(page.locator('#sunOfferAssetWarning')).toContainText('Не загрузились');
expect(await page.evaluate(()=>window.photoAborted)).toBe(true);
const words=await page.locator('#sunClientOfferPreview canvas').evaluateAll(cs=>cs.flatMap(c=>c.__proposalLayout).map(t=>t.text||'').join(' '));
for(const box of demo.boxes.slice(0,6))expect(words.replaceAll('\n',' ')).toContain(box.name);
await page.evaluate(()=>{window.hangPhoto=false;document.getElementById('sunOfferRefresh').click()});
await expect(page.locator('#sunOfferAssetWarning')).toBeHidden();await expect(page.locator('#sunOfferPdf')).toHaveText('Скачать PDF');
});
test('unrelated page fonts cannot keep the proposal font loader waiting',async({page})=>{
await boot(page);
const result=await page.evaluate(async s=>{
Object.defineProperty(document.fonts,'ready',{configurable:true,get:()=>new Promise(()=>{})});
const pages=await sunClientOfferDebugPdfPages({...s,offerTemplateId:'light'});
return {pages:pages.length,fonts:[...document.fonts].filter(f=>f.family.includes('Caterium')).map(f=>f.status)};
},base);
expect(result.pages).toBeGreaterThan(0);expect(result.fonts).toEqual(['loaded','loaded','loaded']);
});
test('closing a loading offer cancels its response body and does not overwrite a later offer',async({page})=>{
await boot(page);
await page.evaluate(d=>{
boxes=structuredClone(d.boxes.slice(0,2));orders=boxes.map((b,i)=>({id:'cancel-'+i,event:'Мероприятие '+i,lines:[{id:b.id,qty:1}],total:b.price,status:'Новый',clientOfferTemplateId:i?'event-ticket':'light'}));
const fetchOriginal=window.fetch;window.bodyStarted=false;window.bodyAborted=false;
window.fetch=async(input,init)=>{
if(String(input.url||input).includes('bruschetta-tomato.webp')){
init.signal.addEventListener('abort',()=>{window.bodyAborted=true},{once:true});
return {ok:true,blob:()=>{window.bodyStarted=true;return new Promise(()=>{})}};
}
return fetchOriginal(input,init);
};
window.firstOfferDone=false;void sunOpenClientOffer('cancel-0').then(()=>{window.firstOfferDone=true});
},demo);
await expect.poll(()=>page.evaluate(()=>window.bodyStarted)).toBe(true);
await page.evaluate(()=>document.getElementById('sunOfferClose').click());
await expect.poll(()=>page.evaluate(()=>window.bodyAborted&&window.firstOfferDone)).toBe(true);
await page.evaluate(()=>sunOpenClientOffer('cancel-1'));
await expect(page.locator('#sunClientOfferPreview canvas').first()).toHaveAttribute('data-sun-proposal-template','event-ticket');
const words=await page.locator('#sunClientOfferPreview canvas').evaluateAll(cs=>cs.flatMap(c=>c.__proposalLayout).map(t=>t.text||'').join(' '));
expect(words).toContain('Мероприятие 1');expect(words).not.toContain('Мероприятие 0');
await expect(page.locator('#sunOfferPdf')).toBeEnabled();await expect(page.locator('#sunOfferAssetWarning')).toBeHidden();
});
test('a render error shows a retry action instead of leaving a disabled loading button',async({page})=>{
await boot(page);
await page.evaluate(s=>{
boxes=[{id:'fixture',name:s.items[0].name,price:12000,category:0,weight:'1200 г',photo:''}];orders=[{id:'retry-offer',event:s.event,lines:[{id:'fixture',qty:2}],total:24000,status:'Новый'}];
window.realProposalPDF=CateriumProposalPDF;window.CateriumProposalPDF={...CateriumProposalPDF,renderPages:async()=>{throw new Error('Simulated render failure')}};
void sunOpenClientOffer('retry-offer');
},base);
await expect(page.locator('#sunClientOfferPreview')).toContainText('Не удалось подготовить предложение');
await expect(page.locator('#sunOfferPdf')).not.toHaveText('Готовлю просмотр…');await expect(page.locator('#sunOfferRefresh')).toBeEnabled();
await page.evaluate(()=>{window.CateriumProposalPDF=window.realProposalPDF;document.getElementById('sunOfferRefresh').click()});
await expect(page.locator('#sunClientOfferPreview canvas').first()).toBeVisible();await expect(page.locator('#sunOfferPdf')).toBeEnabled();
});
async function boot(page){
await page.route('https://**',r=>r.abort());
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>Boolean(window.sunClientOfferDebugPdfPages&&window.SunOfferTemplate));
}
const base={brandName:'Компания Лист',brandCity:'Казань',client:'Анна и Михаил',event:'Ужин для друзей',date:'2026-10-24',guests:20,foodGrams:8000,pricing:{base:24000,manual:2000,promo:0,discount:2000,itemsTotal:22000,delivery:1000,total:23000},items:[{id:'fixture',name:'Брускетта с печёными овощами, творожным сыром и зеленью',categoryId:0,categoryName:'Боксы',qty:2,unitPrice:12000,sum:24000,weight:'1200 г',composition:['Томаты, баклажаны, перец, сливочный сыр и зелень'],photoData:''}]};
function validLayout(pages){
return pages.flatMap((p,index)=>p.__proposalLayout.filter(b=>b.x<35||b.x+b.w>965||b.y<0||b.y+b.h>1400).map(b=>({page:index+1,...b})));
}
test('all six selections render their actual distinct covers and local Cyrillic fonts',async({page})=>{
test.setTimeout(90000);await boot(page);
const result=await page.evaluate(async s=>{
const out=[];
for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await sunClientOfferDebugPdfPages({...s,offerTemplateId:id});
const text=pages.flatMap(p=>p.__proposalLayout).map(x=>x.text).join('\n');
out.push({id,cover:pages[0].dataset.sunProposalTemplate,width:pages[0].width,pages:pages.length,text,pixel:pages[0].toDataURL('image/jpeg',.1)});
pages.forEach(c=>{c.width=0;c.height=0});
}
return {out,fonts:[...document.fonts].filter(f=>f.family.includes('Caterium')).map(f=>f.status),choices:SunOfferTemplate.list().map(t=>t.id)};
},base);
expect(result.out).toHaveLength(6);expect(result.choices).toHaveLength(6);
expect(result.fonts).toEqual(['loaded','loaded','loaded']);
expect(new Set(result.out.map(r=>r.pixel)).size).toBe(6);
for(const row of result.out){expect(row.cover).toBe(row.id);expect(row.width).toBe(2400);expect(row.text.replaceAll('\n',' ')).toContain(base.items[0].name);expect(row.text).toContain('23');expect(row.text).not.toMatch(/Солнце|SUN \/ CATERING/)}
});
test('flow preserves long Cyrillic names and compositions across pages without text collisions',async({page})=>{
test.setTimeout(90000);await boot(page);
const results=await page.evaluate(async({s,validator})=>{
const valid=eval('('+validator+')'),out=[];
const long='Очень длинное название блюда с запечённым баклажаном и сливочным сыром ';
const composition='НачалоСостава '+('Томаты и печёный перец, сливочный сыр; '.repeat(130))+' КонецСостава';
const sample={...s,event:long.repeat(3),client:long.repeat(2),items:[{...s.items[0],name:long.repeat(4),composition:[composition]}]};
for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await sunClientOfferDebugPdfPages({...sample,offerTemplateId:id});
const layout=pages.flatMap(p=>p.__proposalLayout),text=layout.map(b=>b.text).join(' ').replace(/\s+/g,' ');
const collisions=[];
pages.slice(1).forEach((p,index)=>{const list=p.__proposalLayout;for(let i=0;i<list.length;i++)for(let j=i+1;j<list.length;j++){const a=list[i],b=list[j];if(Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x)>2&&Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y)>2)collisions.push({page:index+2,a:a.role,b:b.role})}});
out.push({id,bounds:valid(pages),collisions,text});pages.forEach(c=>{c.width=0;c.height=0});
}return out;
},{s:base,validator:validLayout.toString()});
for(const r of results){expect(r.bounds).toEqual([]);expect(r.collisions).toEqual([]);expect(r.text).toContain('НачалоСостава');expect(r.text).toContain('КонецСостава');expect(r.text).not.toContain('…')}
});
test('legacy choices resolve to a selectable design without changing saved orders',async({page})=>{
await page.addInitScript(()=>localStorage.setItem('sunOfferTemplateV1',JSON.stringify({id:'cream-elegance'})));
await boot(page);
const result=await page.evaluate(async s=>{
const order={id:'old-design',clientOfferTemplateId:'emerald-gold',clientOfferSnapshot:{...s,offerTemplateId:'emerald-gold'}};
orders=[order];const before=JSON.stringify(order);
const pages=await sunClientOfferDebugPdfPages(order.clientOfferSnapshot);
return {default:SunOfferTemplate.get().id,cover:pages[0].dataset.sunProposalTemplate,unchanged:before===JSON.stringify(order),valid:CateriumProposalPDF.IDS.every(id=>CateriumProposalPDF.CURATED_IDS.includes(CateriumProposalPDF.resolveTemplate(id)))};
},base);
expect(result).toEqual({default:'light',cover:'midnight-glass',unchanged:true,valid:true});
});
test('transparent wide, tall and white PNG logos keep their proportions and remain visible on every design',async({page})=>{
test.setTimeout(90000);await boot(page);
const result=await page.evaluate(async s=>{
const rows=[];
for(const [shape,color] of [['wide','#214735'],['tall','#214735'],['wide','#ffffff']]){
const c=document.createElement('canvas');c.width=1400;c.height=800;const ctx=c.getContext('2d');ctx.fillStyle=color;
const w=shape==='wide'?900:160,h=shape==='wide'?150:560;ctx.fillRect(200,100,w,h);
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
const data=await CateriumBranding.saveLogo(new File([blob],'company.png',{type:'image/png'}));
const saved=new Image();saved.src=data;await saved.decode();
for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await CateriumProposalPDF.renderPages({...s,offerTemplateId:id,logo:data});
const image=pages[0].__proposalImages[0],canvas=pages[0],scale=canvas.width/1000;
const bg=Array.from(canvas.getContext('2d').getImageData(20*scale,80*scale,1,1).data);
const colorAtLogo=Array.from(canvas.getContext('2d').getImageData((image.x+image.w/2)*scale,(image.y+image.h/2)*scale,1,1).data);
const collisions=pages.flatMap(p=>(p.__proposalImages||[]).flatMap(im=>p.__proposalLayout.filter(b=>Math.min(im.x+im.w,b.x+b.w)-Math.max(im.x,b.x)>2&&Math.min(im.y+im.h,b.y+b.h)-Math.max(im.y,b.y)>2)));
rows.push({id,shape,color,w:image.w,h:image.h,aspect:image.w/image.h,sourceAspect:saved.width/saved.height,bg,colorAtLogo,collisions});
pages.forEach(p=>{p.width=0;p.height=0});
}
}
return rows;
},base);
expect(result).toHaveLength(18);
for(const r of result){
expect(r.aspect).toBeCloseTo(r.sourceAspect,5);expect(r.collisions).toEqual([]);
if(r.shape==='wide')expect(r.w).toBeGreaterThan(250);else expect(r.h).toBe(76);
expect(r.colorAtLogo).not.toEqual(r.bg);
if(r.color==='#ffffff'){expect(r.bg[0]).toBeLessThan(50);expect(r.colorAtLogo[0]).toBeGreaterThan(245)}
}
});
test('multi-page menus keep a normal price breakdown and grand total together',async({page})=>{
await boot(page);
const result=await page.evaluate(async s=>{
const rows=[];
for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await CateriumProposalPDF.renderPages({...s,offerTemplateId:id,items:Array.from({length:9},(_,i)=>({...s.items[0],name:s.items[0].name+' '+i}))});
rows.push({id,prices:pages.map((p,i)=>p.__proposalLayout.some(b=>b.role==='pricing-value')?i:-1).filter(i=>i>=0),total:pages.findIndex(p=>p.__proposalLayout.some(b=>b.role==='total-value'))});
pages.forEach(p=>{p.width=0;p.height=0});
}return rows;
},base);
for(const r of result){expect(r.prices).toHaveLength(1);expect(r.prices[0]).toBe(r.total)}
});
test('offer settings hide personal data and optional blocks; custom gallery reaches the PDF',async({page})=>{
await boot(page);
const result=await page.evaluate(async s=>{
const c=document.createElement('canvas');c.width=40;c.height=40;const ctx=c.getContext('2d');ctx.fillStyle='#891552';ctx.fillRect(0,0,40,40);const gallery=c.toDataURL();
window.SunOfferWorkspaceV1769={galleryFor:()=>[gallery]};
window.SunClientOfferSettings={get:()=>({showClientName:false,showDate:false,showGuests:false,showGallery:true,showBoxCount:false,showPriceBreakdown:false,showAmountPerGuest:false,showControl:false,showExtras:false,showFooterNote:false,texts:{menuTitle:'Специальное меню'}})};
const draws=[],draw=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(image,...a){draws.push(image.src);return draw.call(this,image,...a)};
try{
const pages=await sunClientOfferDebugPdfPages({...s,offerTemplateId:'light',orderId:'local-fixture'}),text=pages.flatMap(p=>p.__proposalLayout).map(b=>b.text).join(' ');
return {text,gallery:draws.includes(gallery),bounds:pages.flatMap(p=>p.__proposalLayout.filter(b=>b.y+b.h>1400))};
}finally{CanvasRenderingContext2D.prototype.drawImage=draw}
},base);
expect(result.text).toContain('Специальное меню');expect(result.text).not.toContain(base.client);expect(result.text).not.toContain('октября');expect(result.text).not.toContain('Скидка');expect(result.text).not.toContain('гостей');expect(result.gallery).toBe(true);expect(result.bounds).toEqual([]);
});
test('per-order picker presents six curated designs without redundant collection headings',async({page})=>{
await boot(page);
await page.evaluate(()=>{
const grid=document.createElement('div');grid.className='sun-v1764-offer-template-grid';
for(const id of CateriumProposalPDF.CURATED_IDS){const b=document.createElement('button');b.dataset.v1764OfferTemplate=id;grid.appendChild(b)}document.body.appendChild(grid);
});
await expect(page.locator('.sun-v1764-offer-template-grid button')).toHaveCount(6);
await expect(page.locator('.sun-v1767-group-label')).toHaveCount(0);
});
test('an actual offer downloads the same A4 pages shown in its preview',async({page})=>{
test.setTimeout(60000);await boot(page);
await page.evaluate(async s=>{
boxes=[{id:'pdf-local',name:s.items[0].name,price:12000,category:0,weight:'1200 г',composition:s.items[0].composition,photo:''}];
orders=[{id:'pdf-local-order',event:s.event,contact:s.client,date:s.date,time:'18:00',guestsCount:20,lines:[{id:'pdf-local',qty:2}],total:24000,status:'Новый',clientOfferTemplateId:'event-ticket'}];
orders[0].clientOfferSnapshot=await sunClientOfferDebugCreateSnapshot(orders[0]);delete orders[0].clientOfferSnapshot.offerTemplateId;
await window.sunOpenClientOffer('pdf-local-order');
},base);
await expect(page.locator('#sunClientOfferPreview canvas').first()).toHaveAttribute('data-sun-proposal-template','event-ticket');
const pageCount=await page.locator('#sunClientOfferPreview canvas').count();
const downloadPromise=page.waitForEvent('download');
await page.evaluate(()=>window.sunClientOfferDownload());
const download=await downloadPromise,stream=await download.createReadStream(),chunks=[];
for await(const chunk of stream)chunks.push(chunk);
const body=Buffer.concat(chunks).toString('latin1');
expect(body.startsWith('%PDF-1.4')).toBe(true);
expect((body.match(/\/Type \/Page\b/g)||[]).length).toBe(pageCount);
expect((body.match(/\/MediaBox \[0 0 595\.28 841\.89\]/g)||[]).length).toBe(pageCount);
expect(body).toContain('/Width 2400');
// Cloud sync re-emits the default template even when this order's design is unchanged.
const stable=await page.evaluate(()=>{
const canvas=document.querySelector('#sunClientOfferPreview canvas');
window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{id:'cream-elegance'}}));
return canvas===document.querySelector('#sunClientOfferPreview canvas')&&!document.getElementById('sunOfferPdf').disabled;
});expect(stable).toBe(true);
await page.evaluate(()=>SunUXFixV1764.persistOfferTemplate('pdf-local-order','light'));
await expect(page.locator('#sunClientOfferPreview canvas').first()).toHaveAttribute('data-sun-proposal-template','light');
});