import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; // How the notepad sends changes: one at a time, in order, and keeping the // keyboard's place in the list when it is drawn again. const CELEBRATIONS = { levels: { one: {lead: 'Nice one!', note: 'x'}, two: {lead: 'Two!', note: 'x'}, three: {lead: 'Three!', note: 'x'}, many: {lead: 'Many!', note: 'x'}, }, slow: 'Slow and steady.', crossed_off: 'Crossed off "%{name}"', undo: 'Undo', done: {one: '1 job done this week.', other: '%{count} jobs done this week.'}, }; const row = (id, name) => `
${name}
`; const list = `
${row(1, 'Water')}${row(2, 'Weed')}
`; const page = () => `
${list}
Slow and steady.`; const answer = (html = list, done = '0') => ({ok: true, headers: {get: () => done}, text: () => Promise.resolve(html)}); const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const tick = (id) => document.querySelector(`.notepad-job[data-id="${id}"] .notepad-tick`); const box = (id) => tick(id).querySelector('.notepad-box'); const submit = (form) => form.dispatchEvent(new Event('submit', {bubbles: true, cancelable: true})); // The script wires itself up when the page has loaded. Import it fresh for each // test and run that step by hand, so earlier tests' wiring doesn't pile up. const start = async () => { vi.resetModules(); const listen = vi.spyOn(document, 'addEventListener').mockImplementation(() => {}); await import('./job_notepad'); const handlers = listen.mock.calls.filter(([name]) => name === 'DOMContentLoaded').map(([, handler]) => handler); listen.mockRestore(); handlers.forEach((handler) => handler()); }; let fetchMock; beforeEach(() => { document.body.innerHTML = page(); fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { document.body.innerHTML = ''; }); describe('sending changes', () => { it('sends one at a time, the next only once the one before has landed', async () => { let finishFirst; fetchMock.mockReturnValueOnce(new Promise((resolve) => { finishFirst = resolve; })).mockResolvedValue(answer()); await start(); submit(tick(1)); submit(tick(2)); await flush(); expect(fetchMock).toHaveBeenCalledTimes(1); finishFirst(answer()); await flush(); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('sends them in the order they were made', async () => { fetchMock.mockResolvedValue(answer()); await start(); submit(tick(2)); submit(tick(1)); await flush(); await flush(); expect(fetchMock.mock.calls.map(([url]) => url.split('/').pop())).toEqual(['weed', 'water']); }); it('does not let an older answer, arriving last, replace a newer list', async () => { let finishFirst; fetchMock .mockReturnValueOnce(new Promise((resolve) => { finishFirst = resolve; })) .mockResolvedValueOnce(answer('
', '2')); await start(); submit(tick(1)); submit(tick(2)); await flush(); finishFirst(answer('
', '1')); await flush(); await flush(); expect(document.querySelector('#notepad-groups #newer')).not.toBeNull(); expect(document.querySelector('#notepad-groups #older')).toBeNull(); expect(document.getElementById('notepad-snail-caption').textContent).toBe('Slow and steady. 2 jobs done this week.'); }); it('goes on with the next change when one fails', async () => { fetchMock .mockResolvedValueOnce({ok: false, status: 500, headers: {get: () => null}, text: () => Promise.resolve('')}) .mockResolvedValue(answer()); await start(); submit(tick(1)); submit(tick(2)); await flush(); await flush(); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe('after the list is drawn again', () => { it('puts focus back on the tick box that had it', async () => { fetchMock.mockResolvedValue(answer()); await start(); box(1).focus(); submit(tick(1)); await flush(); expect(document.activeElement).toBe(box(1)); }); it('puts focus back on a menu button that had it', async () => { fetchMock.mockResolvedValue(answer()); await start(); document.querySelector('.notepad-job[data-id="2"] .record-actions-toggle').focus(); submit(tick(1)); await flush(); expect(document.activeElement).toBe(document.querySelector('.notepad-job[data-id="2"] .record-actions-toggle')); }); it('does not take focus back from where it has moved to', async () => { fetchMock.mockResolvedValue(answer()); await start(); box(1).focus(); submit(tick(1)); document.querySelector('.notepad-add input[type="text"]').focus(); await flush(); expect(document.activeElement).toBe(document.querySelector('.notepad-add input[type="text"]')); }); it('leaves focus alone when the job is no longer in the list', async () => { fetchMock.mockResolvedValue(answer(`
${row(2, 'Weed')}
`)); await start(); box(1).focus(); submit(tick(1)); await flush(); expect(document.activeElement).not.toBe(box(2)); }); });