caterium-app/tests/training-catalog.spec.mjs
pavlov346346-source 716a79c58d
Optional per-profile training catalog with reversible example visibility (#35)
Add Settings > Учебный каталог, off by default and available on ordinary writable profiles. Load ready example boxes, photos, TTKs and linked sample inventory additively; preserve own data, saved orders and edited examples when hiding or re-enabling. Respect company/profile scope, read-only permissions, failed downloads and tenant changes. Preserve the opened recipe guide across real catalog refreshes on iPhone. Integrated feature checks and full pull-request QA passed. Standard main QA and exact-asset production UI verification remain in place.
2026-09-20 12:18:40 +03:00

97 lines
11 KiB
JavaScript
Raw Permalink 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 {trainingInit} from './training-catalog-fixture.mjs';
import {demo} from '../ops/demo/trial-demo-data.mjs';
test.use({serviceWorkers:'block'});
const nav=page=>page.locator('header nav');
const setting=page=>page.locator('#ctTrainingEnabled');
async function settings(page){await nav(page).getByRole('button',{name:'Настройки',exact:true}).click();await expect(setting(page)).toBeVisible();}
async function boot(page){
page.on('dialog',d=>d.accept());
await page.route('**/*',route=>{const r=route.request(),u=new URL(r.url());return r.method()==='GET'&&u.origin==='http://127.0.0.1:4173'&&!u.pathname.startsWith('/api/')?route.continue():route.abort();});
await page.addInitScript(trainingInit);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>window.CateriumDataV1773&&window.SunOpsUXV1762&&window.CateriumTrialDemo&&window.__cateriumOrderEnhancementsV1775);
await expect(page.locator('body > header')).toBeVisible();await settings(page);
}
async function toggle(page,on){await setting(page).setChecked(on);await expect(setting(page)).toBeEnabled();await expect.poll(()=>page.evaluate(()=>CateriumTrainingCatalog.enabled())).toBe(on);}
const state=page=>page.evaluate(()=>Object.fromEntries(['sunBoxes','sunStock','sunSuppliers','sunOrders','sunStockMoves','sunTrialDemoV1'].map(k=>[k,localStorage.getItem(k)])));
const own=page=>page.evaluate(()=>({box:JSON.parse(localStorage.sunBoxes).find(x=>x.id==='own-box'),stock:JSON.parse(localStorage.sunStock).find(x=>x.id==='own-stock'),supplier:JSON.parse(localStorage.sunSuppliers).find(x=>x.id==='own-supplier'),orders:localStorage.sunOrders,moves:localStorage.getItem('sunStockMoves')}));
test('training is opt-in in an existing paid profile, adds complete examples, hides them and preserves originals',async({page},info)=>{
test.setTimeout(60000); // Full multi-page round trip plus reload in iPhone WebKit.
await boot(page);await expect(setting(page)).not.toBeChecked();
const before=await own(page);expect(await page.evaluate(()=>boxes.length)).toBe(1);
await toggle(page,true);expect(await page.evaluate(()=>boxes.length)).toBe(45);
expect(await own(page)).toEqual(before);
const source=await page.evaluate(()=>boxes.find(x=>x.id==='demo-v1-bruschetta-tomato'));
expect(source.photo).toBeTruthy();expect(source.composition.length).toBeGreaterThan(0);expect(source.ttk.rows.length).toBeGreaterThan(0);expect(source.ingredients.some(i=>i[0]==='Учебное: Томаты')).toBe(true);
await page.locator('#ctTrainingSettings').scrollIntoViewIfNeeded();
await page.screenshot({path:info.outputPath('training-settings.png'),animations:'disabled'});
await nav(page).getByRole('button',{name:'Меню',exact:true}).click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(11);
await expect(page.locator('[data-menu-item-v1762="demo-v1-bruschetta-tomato"] .ct-training-badge')).toHaveText('Учебное');
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();
await page.getByRole('button',{name:'Банкетное меню',exact:true}).click();await expect(page.locator('[data-banquet-row]')).toHaveCount(18);
await settings(page);await toggle(page,false);
await nav(page).getByRole('button',{name:'Меню',exact:true}).click();await page.locator('[data-menu-cat-v1762="0"]').click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await page.getByRole('button',{name:'Банкетное меню',exact:true}).click();await expect(page.locator('[data-banquet-row]')).toHaveCount(0);await page.getByRole('button',{name:'Боксы',exact:true}).click();await expect(page.locator('#tiles .tile:not(.add)')).toHaveCount(1);await expect(page.locator('#ctTrialDemo')).toHaveCount(0);
await nav(page).getByRole('button',{name:'Склад',exact:true}).click();await expect(page.locator('#stockContent')).not.toContainText('Учебное:');
await nav(page).getByRole('button',{name:'Поставщики',exact:true}).click();await expect(page.locator('#supList')).not.toContainText('Демо:');
expect(await own(page)).toEqual(before);expect(await page.evaluate(()=>boxes.length)).toBe(45);
await page.reload();await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773);await settings(page);await expect(setting(page)).not.toBeChecked();await toggle(page,true);
expect(await page.evaluate(()=>boxes.length)).toBe(45);expect(await own(page)).toEqual(before);
});
test('TTKs, trial order and preparation keep their complete records after hiding and restore without resetting edits',async({page})=>{
await boot(page);await toggle(page,true);
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await expect(page.locator('#ctTrialDemo')).toBeVisible();
await page.evaluate(()=>{window.ctGuideEvents=[];const card=document.getElementById('ctTrialDemo');new MutationObserver(rs=>{ctGuideEvents.push(...rs.map(r=>({type:r.type,attribute:r.attributeName,open:card.querySelector('details')?.open,signature:card.dataset.signature})));}).observe(card,{childList:true,subtree:true,attributes:true,attributeFilter:['open','data-signature']});});
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click();
await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽');
const order=await page.evaluate(()=>orders.find(o=>o.demo===true));expect(order.autoCompletionDisabled).toBe(true);
await page.evaluate(()=>{const items=CateriumDataV1773.catalog.list();const box=items.find(b=>b.id==='demo-v1-bruschetta-tomato');box.price=2222;box.name='Мой учебный рецепт';CateriumDataV1773.catalog.replace(items,{persistLocal:false});const stock=JSON.parse(localStorage.sunStock);stock.find(s=>s.id==='demo-v1-stock-baguette').qty=7;localStorage.setItem('sunStock',JSON.stringify(stock));});
const before=await state(page);await settings(page);await toggle(page,false);
expect(await page.evaluate(id=>orders.find(o=>o.id===id),order.id)).toEqual(order);
expect(await page.evaluate(()=>CateriumDataV1773.catalog.get('demo-v1-bruschetta-tomato').ttk.rows.length)).toBeGreaterThan(0);
await toggle(page,true);
const after=await state(page);for(const k of ['sunBoxes','sunOrders','sunStock','sunSuppliers','sunStockMoves'])expect(after[k]).toBe(before[k]);
});
test('old automatically seeded boxes are hidden until opt-in and their customised prices are not overwritten',async({page})=>{
await boot(page);
await page.evaluate(d=>{d.boxes[0].price=4321;CateriumDataV1773.catalog.replace([...CateriumDataV1773.catalog.list(),...d.boxes],{persistLocal:false});localStorage.setItem('sunTrialDemoV1',JSON.stringify({version:1,batch:d.batch,scenario:d.scenario}));CateriumTrainingCatalog.refresh();},demo);
await nav(page).getByRole('button',{name:'Меню',exact:true}).click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
await settings(page);await toggle(page,true);expect(await page.evaluate(()=>boxes.find(b=>b.id==='demo-v1-bruschetta-tomato').price)).toBe(4321);
});
test('the checkbox is profile- and company-specific; read-only and support access do not gain write rights',async({page})=>{
await boot(page);await toggle(page,true);const before=await state(page);
await page.evaluate(()=>{window.savedSession=SunCloudV2.getSession;SunCloudV2.getSession=()=>({user:{id:'another-user'}});CateriumTrainingCatalog.refresh();});await expect(setting(page)).not.toBeChecked();
await page.evaluate(()=>{SunCloudV2.getSession=window.savedSession;window.savedWorkspace=SunCloudV2.getWorkspace;SunCloudV2.getWorkspace=()=>({id:'another-workspace'});CateriumTrainingCatalog.refresh();});await expect(setting(page)).not.toBeChecked();
// Keep read access, deny writes. Revoking read access too correctly triggers
// the existing RBAC cache redaction and is not a training-catalog mutation.
await page.evaluate(()=>{SunCloudV2.getWorkspace=window.savedWorkspace;window.savedPermission=SunCloudV2.hasPermission;SunCloudV2.hasPermission=p=>p==='app.read'||p.endsWith('.view');CateriumTrainingCatalog.refresh();});await expect(setting(page)).toBeDisabled();
expect(await page.evaluate(()=>CateriumTrainingCatalog.setEnabled(false).then(()=>false,()=>true))).toBe(true);
await page.evaluate(()=>{SunCloudV2.hasPermission=window.savedPermission;SunCloudV2.isSupportMode=()=>true;CateriumTrainingCatalog.refresh();});await expect(setting(page)).toBeDisabled();
expect(await state(page)).toEqual(before);
});
test('failed example download leaves the catalog, inventory and preference unchanged and can be retried',async({page})=>{
await boot(page);const before=await state(page);
await page.route('**/demo/catalog-v1.json?**',route=>route.fulfill({status:503,body:'temporarily unavailable'}));
// A fast rejected download correctly reverts the checkbox before check()
// could assert a checked state. Click and assert the intended error state.
await setting(page).click();await expect(page.locator('#ctTrainingStatus')).toContainText('Не удалось загрузить');await expect(setting(page)).not.toBeChecked();await expect(setting(page)).toBeEnabled();expect(await state(page)).toEqual(before);
await page.unroute('**/demo/catalog-v1.json?**');await toggle(page,true);expect(await page.evaluate(()=>boxes.length)).toBe(45);
});
test('a delayed seed cannot be applied to another company after switching',async({page})=>{
await boot(page);const before=await state(page);let release,requested=false;const wait=new Promise(resolve=>{release=resolve;});
await page.route('**/demo/catalog-v1.json?**',async route=>{requested=true;await wait;await route.continue();});
await setting(page).check();await expect.poll(()=>requested).toBe(true);
await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));SunCloudV2.getWorkspace=()=>({id:'new-company'});window.dispatchEvent(new Event('sun:cloud-state-applied'));});release();
await expect(setting(page)).not.toBeChecked();await page.waitForTimeout(200);expect(await state(page)).toEqual(before);
});
test.afterEach(async({page},info)=>{if(info.status!==info.expectedStatus){const events=await page.evaluate(()=>window.ctGuideEvents||[]).catch(()=>[]);await info.attach('training-guide-events',{body:JSON.stringify(events, null, 2),contentType:'application/json'});console.log('GUIDE EVENTS',JSON.stringify(events));}});