Covers the board.js journal exemption on the 1× card-pulse-local flash. Creates two probe cards via the raw API, flags both as "just created locally" in state.recentlyCreatedIds, runs a render, then asserts that the journal-format one (title 9999-12-31 — a year we can't collide with real entries) skipped the pulse class while the __prefixed__ control card still got it. Both probes are deleted in a finally block so a failed assertion doesn't leave them behind for later suites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
234 lines
9.5 KiB
JavaScript
234 lines
9.5 KiB
JavaScript
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?.();
|
||
}
|
||
});
|
||
});
|