import fs from 'node:fs'; import {test,expect} from '@playwright/test'; const NOW='2026-09-19T09:00:00.000Z',WEEK=7*86400000; const sample={id:'pricing-box',category:0,name:'Праздничный бокс',price:1000,weight:'600 г',pieces:10,ingredients:[['Томаты',1,'кг']]}; async function fixture(page){ page.on('dialog',d=>d.accept()); await page.route('https://**',r=>r.abort()); await page.route('**/api/index.php*',r=>r.abort()); await page.addInitScript(item=>{ const ws='pricing-workspace',user={id:'pricing-user',email:'pricing@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'}; localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:ws,localWorkspaceId:ws,tenantStorageReady:true,autoSync:false})); if(!localStorage.getItem('sunBoxes'))localStorage.setItem('sunBoxes',JSON.stringify([item])); if(!localStorage.getItem('sunOrders'))localStorage.setItem('sunOrders',JSON.stringify(Array.from({length:32},(_,i)=>({id:i+1,contact:`Клиент ${String(i+1).padStart(2,'0')}`,phone:`+7 900 000 ${String(i).padStart(4,'0')}`,address:`Адрес клиента ${i}`,event:'Фуршет',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:item.id,qty:1,price:1000}],total:1000,prepayment:0})))); const features=Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true])); window.supabase={createClient:()=>({ auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})}, rpc:async name=>({data:name==='sun_my_workspaces'?[{id:ws,name:'Проверка',role:'admin',is_active:true,permissions:{}}]:name==='sun_subscription_snapshot'?{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}:name==='sun_is_platform_admin'?false:name==='caterium_trial_demo_status'?{canInstall:false,canUpgrade:false}:null,error:null}), channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){} })}; },sample); await page.clock.setFixedTime(new Date(NOW)); await page.goto('/index.html',{waitUntil:'domcontentloaded'}); await expect(page.locator('body > header')).toBeVisible(); await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.__cateriumOrderEnhancementsV1775); } async function edit(page){ await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click(); await page.locator('[data-menu-item-v1762="pricing-box"]').click(); await expect(page.locator('#ctPromotionEditor')).toBeVisible(); } const saved=page=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(i=>i.id==='pricing-box')); // Pure policy: no backend, no timer is needed to expire a closed application's menu. test('promotion policy validates amounts and dates and never mutates base prices or order snapshots',async({page})=>{ await page.setContent(''); await page.addScriptTag({content:fs.readFileSync('public/core/catalog-pricing.js','utf8')}); const values=await page.evaluate(()=>{ const P=CateriumPricing,now=Date.parse('2026-09-19T09:00:00Z'),end=new Date(now+7*86400000).toISOString(); const item={price:999,promotion:{type:'percent',value:12.5,startsAt:new Date(now).toISOString(),endsAt:end}},before=JSON.stringify(item); return {start:P.price(item,now),beforeEnd:P.price(item,Date.parse(end)-1),atEnd:P.price(item,Date.parse(end)),future:P.price(item,now-1),unchanged:before===JSON.stringify(item),snapshot:P.linePrice({price:700},item,Date.parse(end)),free:P.price({price:999,promotion:{type:'percent',value:100}},now),badAmount:P.price({price:999,promotion:{type:'amount',value:1000}},now),badDate:P.price({price:999,promotion:{type:'amount',value:100,endsAt:'bad'}},now),legacy:P.price({price:800,oldPrice:1000},now),legacyBase:P.basePrice({price:800,oldPrice:1000}),zero:P.linePrice({price:0},item,now)}; }); expect(values).toEqual({start:874.13,beforeEnd:874.13,atEnd:999,future:999,unchanged:true,snapshot:700,free:0,badAmount:999,badDate:999,legacy:800,legacyBase:1000,zero:0}); }); test('compact clients retain search, accessible opening and full details',async({page},info)=>{ await fixture(page); await page.locator('header nav').getByRole('button',{name:'Клиенты',exact:true}).click(); const cards=page.locator('#client-list .client-card');await expect(cards).toHaveCount(32); const size=await cards.first().boundingBox();expect(size.height).toBeLessThanOrEqual(96);expect(size.width).toBeGreaterThanOrEqual(200); expect(await page.locator('#client-list').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true); await page.screenshot({path:info.outputPath('compact-clients.png')}); await page.locator('#client-search').fill('Клиент 07');await expect(cards).toHaveCount(1); await page.locator('.client-open').click();await expect(page.locator('#client-card-title')).toHaveText('Клиент 07'); await expect(page.locator('#client-card-content')).toContainText('Адрес клиента 6'); }); test('percent discount lasts a week, survives reload and expires without changing saved orders',async({page},info)=>{ await fixture(page);await edit(page); await page.locator('#ctPromotionEnabled').check();await page.locator('#ctDiscountValue').fill('15'); await expect(page.locator('#ctPromotionDuration')).toHaveValue('7'); await expect(page.locator('#ctPromotionPreview')).toContainText('850 ₽'); await page.screenshot({path:info.outputPath('promotion-editor.png')}); await page.locator('#saveBoxButton').click(); await expect(page.locator('[data-menu-item-v1762="pricing-box"]')).toContainText('850 ₽'); const item=await saved(page);expect(item.price).toBe(1000);expect(item.promotion).toEqual({type:'percent',value:15,startsAt:NOW,endsAt:new Date(Date.parse(NOW)+WEEK).toISOString()});expect(item.oldPrice).toBeUndefined(); await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click(); await page.locator('#tiles .tile:not(.add)').click();expect(await page.evaluate(()=>draft.lines[0].price)).toBe(850); await page.evaluate(()=>{document.getElementById('event').value='Акционный заказ';document.getElementById('date').value='2099-12-20';window.saveOrder();}); const order=await page.evaluate(()=>JSON.parse(localStorage.sunOrders).at(-1));expect(order.lines[0].price).toBe(850); await page.reload();await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773);await edit(page); await expect(page.locator('#boxPrice')).toHaveValue('1000');await expect(page.locator('#ctPromotionDuration')).toHaveValue('keep'); await page.locator('#boxName').fill('Бокс с прежним сроком');await page.locator('#saveBoxButton').click(); expect((await saved(page)).promotion.endsAt).toBe(item.promotion.endsAt); await page.clock.setFixedTime(new Date(Date.parse(NOW)+WEEK)); await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); await expect(page.locator('[data-menu-item-v1762="pricing-box"]')).toContainText('1 000 ₽'); await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveCount(0); expect(await page.evaluate(id=>JSON.parse(localStorage.sunOrders).find(o=>o.id===id),order.id)).toEqual(order); await page.reload();await page.waitForFunction(()=>window.SunOpsUXV1762);await page.evaluate(()=>window.resetDraft()); await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click(); await page.locator('#tiles .tile:not(.add)').click();expect(await page.evaluate(()=>draft.lines[0].price)).toBe(1000); }); test('ruble discount supports an exact end, invalid values do not close or save the editor, disabling restores base',async({page})=>{ await fixture(page);await edit(page); await page.locator('#ctPromotionEnabled').check();await page.locator('#ctDiscountType').selectOption('amount'); await page.locator('#ctDiscountValue').fill('1500');await page.locator('#saveBoxButton').click(); await expect(page.locator('#ctPromotionError')).toContainText('не может превышать');await expect(page.locator('#saveBoxButton')).toBeVisible();expect((await saved(page)).promotion).toBeUndefined(); await page.locator('#ctDiscountValue').fill('250');await page.locator('#ctPromotionDuration').selectOption('custom'); await page.locator('#ctPromotionEnd').fill('2026-09-18T09:00');await page.locator('#saveBoxButton').click();await expect(page.locator('#ctPromotionError')).toContainText('закончилась'); await page.locator('#ctPromotionEnd').fill('2026-10-01T12:00');await page.locator('#saveBoxButton').click(); await expect(page.locator('[data-menu-item-v1762="pricing-box"]')).toContainText('750 ₽'); await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveText('Акция'); await expect(page.locator('[data-menu-item-v1762="pricing-box"] s')).toHaveCount(0); const item=await saved(page);expect(item.promotion.type).toBe('amount');expect(item.promotion.value).toBe(250);expect(item.promotion.endsAt).toBeTruthy(); await edit(page);await page.locator('#ctPromotionEnabled').uncheck();await page.locator('#saveBoxButton').click(); expect((await saved(page)).price).toBe(1000);expect((await saved(page)).promotion).toBeUndefined(); await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveCount(0); }); test('timer expires an open catalog and menu while keeping an unsaved editor intact',async({page})=>{ await fixture(page);await edit(page); // Use real timers and a near boundary to check the actual scheduling path. const end=await page.evaluate(()=>{ const now=Date.now();boxes[0].promotion={type:'amount',value:100,startsAt:new Date(now).toISOString(),endsAt:new Date(now+1000).toISOString()};persist();CateriumPricing.refresh();return now+1000; }); await page.locator('#boxName').fill('Несохранённое название');await page.locator('#boxName').focus(); await page.clock.setFixedTime(new Date(end)); await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveCount(0,{timeout:5000}); await expect(page.locator('#boxName')).toHaveValue('Несохранённое название');await expect(page.locator('#boxName')).toBeFocused(); expect((await saved(page)).price).toBe(1000); }); test('legacy sale converts without changing its current price and fractional discounts keep cents',async({page})=>{ await fixture(page); await page.evaluate(()=>{boxes[0].price=800;boxes[0].oldPrice=1000;boxes[0].sale=true;persist();CateriumPricing.refresh();}); await edit(page); await expect(page.locator('#boxPrice')).toHaveValue('1000'); await expect(page.locator('#ctDiscountType')).toHaveValue('amount'); await expect(page.locator('#ctDiscountValue')).toHaveValue('200'); await expect(page.locator('#ctPromotionDuration')).toHaveValue('none'); await page.locator('#saveBoxButton').click(); const migrated=await saved(page);expect(migrated.price).toBe(1000);expect(migrated.oldPrice).toBeUndefined();expect(migrated.promotion.value).toBe(200); await edit(page);await page.locator('#boxPrice').fill('999');await page.locator('#ctDiscountType').selectOption('percent');await page.locator('#ctDiscountValue').fill('12.5');await page.locator('#saveBoxButton').click(); await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click();await page.locator('#tiles .tile:not(.add)').click(); expect(await page.evaluate(()=>draft.lines[0].price)).toBe(874.13); await expect(page.locator('#tiles .tile:not(.add) .tile-price')).toContainText('874,13'); expect(await page.evaluate(()=>window.sunBaseOrderTotal(draft))).toBe(874.13); });