import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4'; describe('Board Rendering', () => { it('renders card elements', () => { expect($$('#board [data-id]').length).toBeGreaterThan(-1); }); it('cards are draggable', () => { const card = $('#board [data-id]'); if (!card) return; expect(card.getAttribute('draggable')).toBe('true'); }); it('cards have color bar', () => { const card = $('#board [data-id]'); if (!card) return; expect(card.querySelector('.card-bar')).toExist(); }); it('cards have valid data-id', () => { const card = $('#board [data-id]'); if (!card) return; expect(parseInt(card.dataset.id)).toBeGreaterThan(0); }); }); describe('Board Split Preview', () => { afterEach(() => closeAll()); it('opens split panel', async () => { click('#board-split-toggle'); await sleep(150); expect($('#board-preview').classList.contains('hidden')).toBe(false); click('#board-split-toggle'); await sleep(100); }); it('shows empty state initially', async () => { click('#board-split-toggle'); await sleep(150); expect($('#board-preview-empty').classList.contains('hidden')).toBe(false); click('#board-split-toggle'); await sleep(100); }); it('shows card on click', async () => { click('#board-split-toggle'); await sleep(150); const card = $('#board [data-id]'); if (!card) return; click(card); await sleep(250); expect($('#board-preview-title').textContent.length).toBeGreaterThan(0); click('#board-split-toggle'); await sleep(100); }); it('highlights active card', async () => { click('#board-split-toggle'); await sleep(150); const card = $('#board [data-id]'); if (!card) return; click(card); await sleep(250); expect(card.classList.contains('preview-active')).toBe(true); click('#board-split-toggle'); await sleep(100); }); }); describe('Board: Card Create & Delete', () => { afterEach(() => closeAll()); it('new card appears on board', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_appear__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); expect(document.querySelector(`[data-id="${res.id}"]`)).toExist(); }); it('deleted card gone from board', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_gone__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(100); await fetch(`api/cards/${res.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } }); await window._reloadAll?.(); await sleep(200); expect(document.querySelector(`[data-id="${res.id}"]`)).toBe(null); }); it('deleted card clears split preview', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_split_clear__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); // Open split, select the card if ($('#board-preview')?.classList.contains('hidden')) { click('#board-split-toggle'); await sleep(150); } const el = document.querySelector(`[data-id="${res.id}"]`); if (el) click(el); await sleep(200); // Delete and check preview resets await fetch(`api/cards/${res.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } }); await window._reloadAll?.(); await sleep(200); expect($('#board-preview-empty').classList.contains('hidden')).toBe(false); if (!$('#board-preview')?.classList.contains('hidden')) { click('#board-split-toggle'); await sleep(100); } }); }); describe('Board: Focus restoration after overlay close', () => { afterEach(() => closeAll()); it('closing the editor returns focus to body', async () => { click('#add-btn'); await sleep(200); // Editor is open — focus should be inside the modal (title input) expect(document.activeElement !== document.body).toBe(true); click('#modal-cancel'); await sleep(200); expect(document.activeElement).toBe(document.body); }); it('closing the detail view returns focus to body', async () => { const card = $('#board [data-id]'); if (!card) return; click(card); await sleep(300); if (!$('#detail')?.classList.contains('open')) return; click('#detail-close'); await sleep(200); expect(document.activeElement).toBe(document.body); }); it('closing search returns focus to body', async () => { click('#search-btn'); await sleep(200); if (!$('#search')?.classList.contains('open')) return; expect(document.activeElement?.id).toBe('search-input'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await sleep(200); expect(document.activeElement).toBe(document.body); }); it('arrow keys work after editor round-trip (enter → enter → escape)', async () => { const card = $('#board [data-id]'); if (!card) return; // Focus a card document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await sleep(100); if (!document.querySelector('#board .card-focused')) return; // Enter → detail document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await sleep(300); if (!$('#detail')?.classList.contains('open')) return; // Enter → close detail, open editor document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await sleep(300); if (!$('#modal')?.classList.contains('open')) return; // Escape → close editor document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await sleep(200); expect(document.activeElement).toBe(document.body); // Arrows should work again document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await sleep(100); expect(document.querySelector('#board .card-focused')).toExist(); }); }); describe('Board: new-card pulse', () => { // Journal entries shouldn't get the 1× card-pulse-local flash — // they're daily-log jottings, not "new thing" moments. We use // title "9999-12-31" for the journal probe to ensure we can't // collide with a real user entry; a control card with a // __prefixed__ title verifies the pulse still fires for normal // local creations. it('locally-created journal-format cards skip the pulse', async () => { const token = csrf(); const hJson = { 'Content-Type': 'application/json', 'X-CSRF-Token': token }; const hCsrf = { 'X-CSRF-Token': token }; let journal, control; try { journal = await fetch('api/cards', { method: 'POST', headers: hJson, body: JSON.stringify({ title: '9999-12-31', body: '' }) }).then(r => r.json()); control = await fetch('api/cards', { method: 'POST', headers: hJson, body: JSON.stringify({ title: '__test_pulse_ctrl__', body: '' }) }).then(r => r.json()); expect(journal?.id).toExist(); expect(control?.id).toExist(); // Flag both as "just created locally" so renderBoard routes // them through the local-pulse branch. The journal // exemption then has to filter out the journal one. window.state.recentlyCreatedIds.add(String(journal.id)); window.state.recentlyCreatedIds.add(String(control.id)); await window._reloadAll(); // Wait past the rAF tick so pulse classes have been // applied, but well within the 900ms lifetime so we // can still observe them. await sleep(150); const journalEl = document.querySelector(`.board-card[data-id="${journal.id}"]`); const controlEl = document.querySelector(`.board-card[data-id="${control.id}"]`); expect(journalEl).toExist(); expect(controlEl).toExist(); expect(journalEl.classList.contains('card-pulse-local')).toBe(false); expect(controlEl.classList.contains('card-pulse-local')).toBe(true); } finally { // Clean up even on failure. Backup/restore would catch it // eventually, but between tests we don't want a stray // 9999-12-31 card floating around upsetting other suites. if (journal?.id) await fetch(`api/cards/${journal.id}`, { method: 'DELETE', headers: hCsrf }); if (control?.id) await fetch(`api/cards/${control.id}`, { method: 'DELETE', headers: hCsrf }); await window._reloadAll?.(); } }); });