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;i2&&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'); });