caterium-app/tests/trial-demo.spec.mjs
2026-09-18 10:43:08 +03:00

166 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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}}))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:'<!doctype html><html><body></body></html>'}));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'});
});