import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {addedBlock} from './job_ideas';
// The ideas overlay: opened from the links, choosing an idea without leaving the
// page, and showing the jobs an idea made.
const idea = (key, name) => `
`;
const page = () => `
Ideas
We have 2
`;
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const json = (data) => ({ok: true, json: () => Promise.resolve(data)});
const html = (text) => ({ok: true, text: () => Promise.resolve(text)});
const body = () => document.getElementById('job-ideas-body');
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.
const start = async () => {
vi.resetModules();
const listen = vi.spyOn(document, 'addEventListener').mockImplementation(() => {});
await import('./job_ideas');
const handlers = listen.mock.calls.filter(([name]) => name === 'DOMContentLoaded').map(([, handler]) => handler);
listen.mockRestore();
handlers.forEach((handler) => handler());
};
let fetchMock;
let show;
beforeEach(() => {
document.body.innerHTML = page();
fetchMock = vi.fn();
show = vi.fn();
vi.stubGlobal('fetch', fetchMock);
// Asking for less motion makes every pause end at once.
vi.stubGlobal('matchMedia', () => ({matches: true}));
window.bootstrap = {Modal: {getOrCreateInstance: () => ({show})}};
});
afterEach(() => {
document.body.innerHTML = '';
delete window.bootstrap;
});
describe('addedBlock', () => {
const data = {added_label: 'Added 2 jobs', added: [{id: 1, name: 'Stake', due: '5 Nov'}, {id: 2, name: 'Feed', due: null}]};
it('says what was added', () => {
expect(addedBlock(data).querySelector('.idea-added-label').textContent).toBe('✓ Added 2 jobs');
});
it('lists each job with its day', () => {
expect(Array.from(addedBlock(data).querySelectorAll('li')).map((li) => li.textContent)).toEqual(['5 Nov Stake', 'Feed']);
});
it('brings each job in a moment after the one before', () => {
expect(Array.from(addedBlock(data).querySelectorAll('li')).map((li) => li.style.animationDelay)).toEqual(['0ms', '90ms']);
});
});
describe('opening', () => {
it('loads the ideas into the overlay', async () => {
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
expect(body().querySelectorAll('.idea').length).toBe(1);
});
it('shows the overlay', async () => {
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
expect(show).toHaveBeenCalled();
});
});
describe('arriving with the ideas to show', () => {
afterEach(() => window.history.replaceState(null, '', '/'));
it('opens the overlay', async () => {
window.history.replaceState(null, '', '/members/brenda/jobs?ideas=1');
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
await flush();
expect(show).toHaveBeenCalled();
});
it('takes the ideas out of the address, so a reload does not open it again', async () => {
window.history.replaceState(null, '', '/members/brenda/jobs?ideas=1');
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
expect(window.location.search).toBe('');
});
it('does not open the overlay without it', async () => {
fetchMock.mockResolvedValue(html(idea('a', 'Snip')));
await start();
await flush();
expect(show).not.toHaveBeenCalled();
});
});
describe('choosing an idea', () => {
const added = {
added: [{id: 7, name: 'Plant tomato', due: null}], added_label: 'Added 1 job to your list', count: 1, banner: 'We have 1',
groups: 'Water
Plant tomato
',
list: idea('b', 'Dig'),
};
const choose = async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip') + idea('b', 'Dig'))).mockResolvedValueOnce(json(added));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
submit(body().querySelector('.idea-form'));
await flush();
await flush();
};
it('sends it in the background', async () => {
await choose();
expect(new URL(fetchMock.mock.calls[1][0]).pathname).toBe('/job_ideas');
});
it('sends the token', async () => {
await choose();
expect(fetchMock.mock.calls[1][1].headers['X-CSRF-Token']).toBe('token-123');
});
it('marks the new job in the notepad', async () => {
await choose();
expect(document.querySelector('.notepad-job[data-id="7"]').classList.contains('just-added')).toBe(true);
});
it('does not mark the jobs that were there', async () => {
await choose();
expect(document.querySelector('.notepad-job[data-id="1"]').classList.contains('just-added')).toBe(false);
});
it('puts the ideas that are left in the overlay', async () => {
await choose();
expect(Array.from(body().querySelectorAll('.idea')).map((el) => el.dataset.key)).toEqual(['b']);
});
it('updates the words in the box', async () => {
await choose();
expect(document.querySelector('.notepad-ideas-text').textContent).toBe('We have 1');
});
it('hides the box when there are no ideas left', async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip'))).mockResolvedValueOnce(json({...added, count: 0, list: ''}));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
submit(body().querySelector('.idea-form'));
await flush();
await flush();
expect(document.querySelector('.notepad-ideas').hidden).toBe(true);
});
it('does not send the choice again once the server has answered', async () => {
await choose();
const form = body().querySelector('.idea-form');
expect(form === null || form.submit === HTMLFormElement.prototype.submit).toBe(true);
});
it('sends the choice the ordinary way if it could not be sent', async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip'))).mockRejectedValueOnce(new Error('offline'));
await start();
document.querySelector('[data-job-ideas-open]').click();
await flush();
const form = body().querySelector('.idea-form');
form.submit = vi.fn();
submit(form);
await flush();
expect(form.submit).toHaveBeenCalled();
});
});