import { describe, test, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'; import { createServer } from 'node:http'; import { createLab } from '../public/model.js'; const lab = createLab(process.env.LAB_VARIANT || 'fixed'); let server, base; let cleanups=[]; const tracked=fn=>{cleanups.push(fn);return fn;}; beforeAll(async () => { server = createServer((req,res) => { res.setHeader('Content-Type','application/json'); if(req.url === '/customer') { res.end(JSON.stringify({id:7,name:'Анна'})); } else if(req.url === '/invalid') { res.end(JSON.stringify({name:7})); } else { res.statusCode=503; res.end(JSON.stringify({error:'Service unavailable'})); } }); await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); base='http://127.0.0.1:'+server.address().port; }); afterAll(async () => { server.closeAllConnections(); await new Promise(resolve=>server.close(resolve)); }); afterEach(() => { for(const cleanup of cleanups) cleanup();cleanups=[];vi.clearAllTimers();vi.useRealTimers();vi.restoreAllMocks();document.body.replaceChildren(); }); describe('01 · Цена и границы', () => { test('3 × 19900 копеек со скидкой 10% = 53730',()=>expect(lab.total(19900,3,10)).toBe(53730)); test('нулевая цена остаётся нулём',()=>expect(lab.total(0,1)).toBe(0)); test('дробное количество отклоняется',()=>expect(()=>lab.total(100,1.5)).toThrow(RangeError)); test('скидка выше 100% отклоняется',()=>expect(()=>lab.total(100,1,101)).toThrow(RangeError)); test('небезопасное целое и переполнение суммы отклоняются',()=>{expect(()=>lab.total(1e308,100)).toThrow(RangeError);expect(()=>lab.total(Number.MAX_SAFE_INTEGER,2)).toThrow(RangeError);}); }); describe('02 · Таблица случаев', () => { test.each([ ['anna@example.com',true],[' anna@example.com ',true],['',false],['anna@',false],['a b@example.com',false],['example.com',false] ])('email %j → %s',(value,expected)=>expect(lab.validateEmail(value)).toBe(expected)); }); describe('03 · Async и реальный HTTP', () => { test('await получает JSON настоящего локального сервера',async()=>await expect(lab.loadCustomer(fetch,base+'/customer')).resolves.toEqual({id:7,name:'Анна'})); test('HTTP 503 отклоняет Promise',async()=>await expect(lab.loadCustomer(fetch,base+'/unavailable')).rejects.toThrow('HTTP 503')); test('неверная форма данных отклоняется',async()=>await expect(lab.loadCustomer(fetch,base+'/invalid')).rejects.toThrow('Некорректный клиент')); }); describe('04 · Mock границы API', () => { test('передаёт копейки и очищенный email',async()=>{ const post=vi.fn().mockResolvedValue({id:42}); await expect(lab.saveOrder({post},' anna@example.com ',53730)).resolves.toEqual({id:42}); expect(post).toHaveBeenCalledExactlyOnceWith('/orders',{email:'anna@example.com',totalCents:53730}); }); test('не отправляет неверный контакт',async()=>{const post=vi.fn();await expect(lab.saveOrder({post},'bad',10)).rejects.toThrow();expect(post).not.toHaveBeenCalled();}); }); describe('05 · Debounce и fake timers', () => { test('оставляет только последний поисковый запрос',()=>{ vi.useFakeTimers(); const callback=vi.fn(), search=lab.debounceSearch(callback,300); search('а');search('ан');search('анна');vi.advanceTimersByTime(299);expect(callback).not.toHaveBeenCalled(); vi.advanceTimersByTime(1);expect(callback).toHaveBeenCalledExactlyOnceWith('анна');search.cancel(); }); test('cancel очищает отложенную работу',()=>{ vi.useFakeTimers();const callback=vi.fn(),search=lab.debounceSearch(callback);search('x');search.cancel();vi.runAllTimers();expect(callback).not.toHaveBeenCalled(); }); }); describe('06 · DOM-форма в JSDOM',()=>{ test('не сохраняет пустой email и фокусирует поле',()=>{ const cleanup=tracked(lab.mountContactForm(document.body));document.querySelector('form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})); expect(document.querySelector('[role=status]').textContent).toBe('Укажите корректный email'); expect(document.activeElement).toBe(document.querySelector('input'));expect(document.querySelector('input').getAttribute('aria-invalid')).toBe('true');cleanup(); }); test('сохраняет валидный контакт',()=>{const cleanup=tracked(lab.mountContactForm(document.body));document.querySelector('input').value='anna@example.com';document.querySelector('form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));expect(document.querySelector('[role=status]').textContent).toBe('Сохранено: anna@example.com');cleanup();}); }); describe('07 · Disclosure: DOM preflight, не Browser Mode',()=>{ test('синхронизирует aria-expanded с панелью',()=>{ const cleanup=tracked(lab.mountDisclosure(document.body)),toggle=document.querySelector('#details-toggle');toggle.click(); expect(toggle.getAttribute('aria-expanded')).toBe('true');expect(document.querySelector('#details-panel').hidden).toBe(false);cleanup(); }); test('Escape закрывает и возвращает фокус',()=>{ const cleanup=tracked(lab.mountDisclosure(document.body)),toggle=document.querySelector('#details-toggle');toggle.click();document.querySelector('input').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true})); expect(document.querySelector('#details-panel').hidden).toBe(true);expect(document.activeElement).toBe(toggle);cleanup(); }); }); describe('08 · Snapshot и coverage',()=>{ test('стабильное представление заказа',()=>{ expect(lab.summary({id:42,customer:'Анна',cents:53730,paid:false})).toMatchInlineSnapshot(` { "amount": "537.30", "currency": "RUB", "customer": "Анна", "id": 42, "state": "pending", } `); }); test('самовывоз бесплатен; другие ветки намеренно не проверены здесь',()=>expect(lab.deliveryFee('pickup')).toBe(0)); }); describe('09 · CI: все проверки обязательны',()=>{ test('падение одной проверки запрещает выпуск',()=>expect(lab.canPublish([true,false,true])).toBe(false)); test('полностью зелёный набор разрешает следующий шаг',()=>expect(lab.canPublish([true,true])).toBe(true)); test('пустой набор не является доказательством',()=>expect(lab.canPublish([])).toBe(false)); });