import {test,expect} from '@playwright/test'; import fs from 'node:fs'; import {demo} from '../ops/demo/trial-demo-data.mjs'; import {banquetDemo} from '../ops/demo/trial-banquet-data.mjs'; import {extrasDemo} from '../ops/demo/trial-extras-data.mjs'; const completeDemo={...demo,boxes:[...demo.boxes,...banquetDemo.banquet,...extrasDemo.items],stock:[...demo.stock,...banquetDemo.stock,...extrasDemo.stock]}; test('installed app serves old trial PNG links as cached lightweight images even offline',async({page,context})=>{ await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort()); await page.goto('/index.html',{waitUntil:'domcontentloaded'}); await page.waitForFunction(()=>Boolean(navigator.serviceWorker.controller)); const load=()=>page.evaluate(async d=>Promise.all(d.boxes.map(async b=>{const r=await fetch('/'+b.photo);return {type:r.headers.get('content-type'),bytes:(await r.arrayBuffer()).byteLength}})),demo); const online=await load();expect(online.every(r=>r.type.includes('webp'))).toBe(true);expect(online.reduce((s,r)=>s+r.bytes,0)).toBe(1343650); await expect.poll(()=>page.evaluate(async d=>(await Promise.all(d.boxes.map(b=>caches.match('/'+b.photo.replace('.png','.webp'))))).filter(Boolean).length,demo)).toBe(10); await context.setOffline(true);expect(await load()).toEqual(online);await context.setOffline(false); }); async function ready(page){ page.on('dialog',d=>d.accept()); await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort()); await page.goto('/index.html',{waitUntil:'domcontentloaded'}); await page.waitForFunction(()=>window.CateriumDataV1773&&window.CateriumTrialDemo&&window.SunAdminRBACV3); await page.evaluate(d=>{ window.SunCloudV2={getSession:()=>({user:{id:'test'}}),getWorkspace:()=>({id:'test',role:'admin',permissions:{}}),getClient:()=>({rpc:async(name)=>({data:name==='sun_subscription_snapshot'?{access_mode:'full',status:'trialing',plan_name:'Полный',features:Object.fromEntries(['orders','shopping','stock','catalog_view','catalog_edit','suppliers','settings','clients','calendar','print'].map(k=>[k,true]))}:name==='caterium_trial_demo_status'?{eligible:true,installed:true,empty:false,canInstall:false}:null})}),status:()=>({connected:true,signedIn:true,membershipsLoaded:true}),hasPermission:()=>true}; // Hydrate AFTER stock UI boot: the real cloud path must update its closure. for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario,trainingCatalog:{workspaceId:'test',profiles:{test:true}}}}))localStorage.setItem(key,JSON.stringify(value)); boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render(); },completeDemo); await expect(page.locator('#ctTrialDemo')).toBeVisible(); } test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{ await ready(page); await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click(); await expect(page.locator('#ctDemoTtk')).toBeVisible();await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await expect(page.locator('#ctDemoTtk')).toContainText('Брутто'); expect(await page.locator('#ctDemoTtk').evaluate(el=>el.getBoundingClientRect().width<=innerWidth)).toBe(true); await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click(); await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽'); await page.locator('[data-demo-order]').click();expect(await page.evaluate(()=>orders.length)).toBe(1); expect(await page.evaluate(()=>orders[0].autoCompletionDisabled)).toBe(true); await page.locator('[data-demo-go="shopping"]').click(); await expect(page.locator('[data-shop-key="багет|кг"]')).toContainText('0,96'); await expect(page.locator('[data-shop-key="багет|кг"]')).toContainText('230'); await page.getByRole('button',{name:'Склад',exact:true}).first().click(); await page.evaluate(()=>window.stockMovement('demo-v1-stock-baguette','in')); await page.locator('#stockMoveQty').fill('2');await page.locator('#stockSaveMove').click(); expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-v1-stock-baguette').qty)).toBe(2); expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-v1-stock-baguette').cost)).toBe(240); await page.getByRole('button',{name:'Закупки',exact:true}).first().click();await expect(page.locator('[data-shop-key="багет|кг"]')).toHaveCount(0); await page.evaluate(()=>window.writeOffOrderStock(orders[0].id)); const written=await page.evaluate(()=>({qty:JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-v1-stock-baguette').qty,moves:JSON.parse(localStorage.sunStockMoves).length,done:orders[0].stockWrittenOff})); expect(written.qty).toBeCloseTo(1.04,3);expect(written.done).toBe(true);expect(written.moves).toBeGreaterThan(5); await page.evaluate(()=>window.writeOffOrderStock(orders[0].id));expect(await page.evaluate(()=>JSON.parse(localStorage.sunStockMoves).length)).toBe(written.moves); }); test('demo photo assets load and company changes remove the trial guide',async({page})=>{ await ready(page); expect(await page.evaluate(async d=>{const images=await Promise.all(d.boxes.map(b=>new Promise(resolve=>{const i=new Image();i.onload=()=>resolve(i.naturalWidth===1024);i.onerror=()=>resolve(false);i.src=SunSafe.imageAssetSrc(b.photo)})));return images.every(Boolean)},demo)).toBe(true); const sources=await page.locator('#tiles img[src*="demo/images/"]').evaluateAll(images=>images.map(i=>i.getAttribute('src'))); expect(sources).toHaveLength(10);expect(sources.every(s=>s.endsWith('.webp'))).toBe(true); const bytes=demo.boxes.reduce((sum,b)=>sum+fs.statSync('public/'+b.photo.replace('.png','.webp')).size,0);expect(bytes).toBeLessThan(1500000); expect(await page.evaluate(()=>['catalog/001.jpg','data:image/png;base64,AAAA','https://other.example/demo/images/caprese.png','demo/images/custom.png'].map(SunSafe.imageAssetSrc))).toEqual(['catalog/001.jpg','data:image/png;base64,AAAA','https://other.example/demo/images/caprese.png','demo/images/custom.png']); await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));window.SunCloudV2.getSession=()=>null;localStorage.removeItem('sunTrialDemoV1');window.CateriumTrialDemo.render()}); await expect(page.locator('#ctTrialDemo')).toHaveCount(0); }); test('trial banquet categories, per-guest TTK, shopping and stock work together',async({page})=>{ await ready(page); await page.getByRole('button',{name:'Банкетное меню',exact:true}).click(); await expect(page.locator('[data-banquet-row]')).toHaveCount(18); await page.locator('[data-banquet-group="sides"]').click();await expect(page.locator('[data-banquet-row]')).toHaveCount(2); await page.locator('[data-banquet-ttk="demo-banquet-v1-mash"]').click(); await expect(page.locator('#ctDemoTtk')).toContainText('На 1 порцию / 1 гостя'); await expect(page.locator('#ctDemoTtk')).toContainText('150 г');await expect(page.locator('#ctDemoTtk')).toContainText('Цена порции: 160 ₽'); await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click(); await page.locator('[data-banquet-item="demo-banquet-v1-mash"]').check(); await page.locator('[data-banquet-group="desserts"]').click();await expect(page.locator('[data-banquet-row]')).toHaveCount(2); await page.locator('[data-banquet-group="main"]').click();await page.locator('[data-banquet-item="demo-banquet-v1-chicken"]').check(); await page.locator('[data-banquet-guests]').fill('4');await expect(page.locator('[data-banquet-total]')).toHaveText('2 600 ₽'); await page.locator('[data-banquet-apply]').click(); await page.evaluate(()=>{const day=new Date();day.setDate(day.getDate()+1);draft.event='Пробный банкет';draft.date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;draft.time='12:00';draft.autoCompletionDisabled=true;document.getElementById('date').value=draft.date;document.getElementById('time').value=draft.time;window.saveOrder()}); expect(await page.evaluate(()=>orders.at(-1).lines.map(l=>({id:l.id,qty:l.qty})))).toEqual([{id:'demo-banquet-v1-chicken',qty:4},{id:'demo-banquet-v1-mash',qty:4}]); await page.getByRole('button',{name:'Закупки',exact:true}).first().click(); await expect(page.locator('[data-shop-key="картофель|кг"]')).toContainText('0,68'); await expect(page.locator('[data-shop-key="филе куриное сырое|кг"]')).toContainText('0,72'); await page.getByRole('button',{name:'Склад',exact:true}).first().click(); await page.evaluate(()=>window.stockMovement('demo-banquet-v1-stock-potato','in')); await page.locator('#stockMoveQty').fill('1');await page.locator('#stockSaveMove').click(); await page.evaluate(()=>window.writeOffOrderStock(orders.at(-1).id)); expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-banquet-v1-stock-potato').qty)).toBeCloseTo(.32,3); }); test('trial extras populate every tab and supplies reach shopping and stock',async({page})=>{ await ready(page); for(const [label,count] of [['Премиум',3],['Напитки',4],['Посуда',4],['Дополнения',3],['Доставка',2]]){ await page.getByRole('button',{name:label,exact:true}).click(); await expect(page.locator('#tiles .tile:not(.add)')).toHaveCount(count); } await page.getByRole('button',{name:'Напитки',exact:true}).click(); await page.locator('#tiles .tile').filter({hasText:'Вода негазированная'}).click(); await page.locator('#tiles .tile').filter({hasText:'Вода негазированная'}).click(); await page.getByRole('button',{name:'Посуда',exact:true}).click(); await page.locator('#tiles .tile').filter({hasText:'Тарелка одноразовая'}).click(); await page.getByRole('button',{name:'Доставка',exact:true}).click(); await page.locator('#tiles .tile').filter({hasText:'Доставка по городу'}).click(); expect(await page.evaluate(()=>window.sunBaseOrderTotal(draft))).toBe(805); await page.evaluate(()=>{const day=new Date();day.setDate(day.getDate()+1);draft.event='Демо с напитками';draft.date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;draft.time='12:00';draft.autoCompletionDisabled=true;document.getElementById('date').value=draft.date;document.getElementById('time').value=draft.time;window.saveOrder()}); await page.getByRole('button',{name:'Закупки',exact:true}).first().click(); await expect(page.locator('[data-shop-key="демо: вода негазированная 0,5 л|шт."]')).toContainText('2'); await expect(page.locator('[data-shop-key="демо: тарелка одноразовая|шт."]')).toContainText('1'); expect((await page.locator('[data-shop-key]').allTextContents()).every(text=>!text.includes('Доставка по городу'))).toBe(true); await page.getByRole('button',{name:'Склад',exact:true}).first().click(); await page.evaluate(()=>window.stockMovement('demo-extras-v1-stock-water','in')); await page.locator('#stockMoveQty').fill('5');await page.locator('#stockSaveMove').click(); await page.evaluate(()=>window.writeOffOrderStock(orders.at(-1).id)); expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-extras-v1-stock-water').qty)).toBe(3); }); test('phone shows the order before boxes and the top arrow preserves the draft',async({page})=>{ await ready(page); const phone=page.viewportSize().width<=760; const order=page.locator('#new .work > section.card'),catalog=page.locator('#new .work > .catalog'); const bounds=await Promise.all([order.boundingBox(),catalog.boundingBox()]); if(!phone){expect(bounds[1].x).toBeLessThan(bounds[0].x);await expect(page.locator('#ctOrderTop')).toBeHidden();return;} expect(bounds[0].y+bounds[0].height).toBeLessThanOrEqual(bounds[1].y); await expect(page.locator('#orderForm .lines')).toHaveCSS('min-height','0px'); expect(await order.evaluate(el=>el.nextElementSibling?.classList.contains('catalog'))).toBe(true); await page.locator('#deliveryCost').fill('600');await page.locator('#prepayment').fill('100'); await page.locator('#tiles .tile').filter({hasText:'Брускетты с томатами'}).click(); await expect(page.locator('#ctOrderTop')).toBeVisible(); expect(await page.evaluate(()=>draft.lines[0].qty)).toBe(1); await page.getByRole('button',{name:'Наверх к заказу',exact:true}).click(); await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); await expect(page.locator('#deliveryCost')).toHaveValue('600');await expect(page.locator('#prepayment')).toHaveValue('100'); expect(await page.evaluate(()=>draft.lines[0].qty)).toBe(1); await expect(page.locator('#ctOrderTop')).toBeHidden(); await page.setViewportSize({width:1440,height:900}); await expect.poll(()=>order.evaluate(el=>el.previousElementSibling?.classList.contains('sun-work-splitter'))).toBe(true); const wide=await Promise.all([order.boundingBox(),catalog.boundingBox()]);expect(wide[1].x).toBeLessThan(wide[0].x); await page.setViewportSize({width:390,height:844}); await expect.poll(()=>order.evaluate(el=>el.nextElementSibling?.classList.contains('catalog'))).toBe(true); await expect(page.locator('#deliveryCost')).toHaveValue('600'); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); }); test('owner onboarding preserves server demo when saving the company profile',async({page})=>{ await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''}));await page.goto('/index.html'); await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'}); const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testInit:c=>{client=c;session={user:{id:'test-user'}};config.tenantStorageReady=true;config.localWorkspaceId='';config.workspaceId=''},`); await page.addScriptTag({content:'var boxes=[],orders=[];async function compactSunBoxesForStorage(x){return x};'+source}); const result=await page.evaluate(async d=>{ let created=false,saved=null;const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:d.boxes},sunStock:{t:'j',v:d.stock},sunSuppliers:{t:'j',v:d.suppliers},sunTrialDemoV1:{t:'j',v:{version:1,scenario:d.scenario}},sunCompanyProfileV1:{t:'j',v:{email:'owner@example.invalid',name:''}}}}; const row={payload,revision:1,updated_at:new Date().toISOString()}; window.SunCloudV2.testInit({rpc:async(name,args)=>{ if(name==='sun_my_workspaces')return {data:created?[{id:'company',name:'Test',role:'admin',is_active:true}]:[]}; if(name==='sun_create_workspace'){created=true;return {data:'company'}} if(name==='sun_fetch_app_state')return {data:[row]}; if(name==='sun_save_app_state_v17'){saved=structuredClone(args.p_payload);return {data:[{...row,payload:saved,revision:2}]}} return {data:null}; },channel:()=>({on(){return this},subscribe(){return this}})}); await window.SunCloudV2.createTrialWorkspace('My trial company'); return {saved,local:JSON.parse(localStorage.sunBoxes),workspace:window.SunCloudV2.getWorkspace()}; },demo); expect(result.saved.storage.sunBoxes.v).toHaveLength(10);expect(result.local).toHaveLength(10); expect(result.saved.storage.sunStock.v).toEqual(demo.stock);expect(result.saved.storage.sunCompanyProfileV1.v).toMatchObject({name:'My trial company',email:'owner@example.invalid'}); });