caterium-app/tests/client-menu.spec.mjs
pavlov346346-source 424bf8ce87
Compact clients and explicit timed menu promotions (#31)
Add compact accessible client summaries and menu discounts in percent or rubles with explicit durations. Preserve ordinary catalog prices and order line snapshots; derive current prices and expire promotions automatically without background writes. Keep fractional prices, legacy sale compatibility, and regression coverage. Full pull-request QA passed. Publication remains gated by full main QA and byte-for-byte production asset and UI verification.
2026-09-19 09:34:50 +03:00

132 lines
12 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 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('<html><body></body></html>');
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);
});