import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=4'; import { state } from '../../js/modules/state.js'; function ctrlZ() { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })); } /** * Poll `state.cards` until the card with the given id satisfies the * predicate. Lets a test wait for an async click handler's _reloadAll * to land in the in-memory state before triggering the next user * action that reads from it. Use this in place of fixed `sleep(300)` * waits between chained actions — fixed sleeps race against handler * speed when the run executes faster than the original tuning. */ async function waitForStateCard(id, predicate, timeout = 2000) { const start = Date.now(); while (Date.now() - start < timeout) { const card = state.cards.find(c => c.id == id); if (card && predicate(card)) return card; await sleep(30); } throw new Error(`waitForStateCard: card ${id} did not satisfy predicate within ${timeout}ms`); } describe('Undo: Color Change', () => { afterEach(() => closeAll()); it('Ctrl+Z reverts color via context menu', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_color__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; rightClick(cardEl); await sleep(150); click($$('#ctx-items [data-action="color"]')[0]); await sleep(100); click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]')); await sleep(300); let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.color).toBe('#fb7185'); ctrlZ(); await sleep(400); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.color).toBe('#38bdf8'); }); }); describe('Undo: Editor Save', () => { afterEach(() => closeAll()); it('Ctrl+Z reverts title change', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_title_orig__', body: 'original body', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); // Open editor via context menu const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); await sleep(150); click(document.querySelector('#ctx-items [data-action="edit"]')); await sleep(300); // Change title type('#inp-title', '__test_undo_title_changed__'); click('#modal-save-close'); await sleep(400); let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.title).toBe('__test_undo_title_changed__'); // Undo ctrlZ(); await sleep(400); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.title).toBe('__test_undo_title_orig__'); }); it('Ctrl+Z reverts body change', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_body__', body: 'before edit', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); await sleep(150); click(document.querySelector('#ctx-items [data-action="edit"]')); await sleep(300); const bodyEl = $('#editor-body'); bodyEl.value = 'after edit'; bodyEl.dispatchEvent(new Event('input', { bubbles: true })); click('#modal-save-close'); // Wait for both the server patch AND the modal close before firing // Ctrl+Z — the global undo shortcut is guarded by // `!guards.isOpen('modal')`, so if doSave's closeModal() hasn't run // yet (it only fires after the post-save _reloadAll), the undo key // is swallowed silently and the test races the client's save flow. let card; for (let i = 0; i < 40; i++) { await sleep(100); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); const modalClosed = !document.getElementById('modal')?.classList.contains('open'); if ((card?.body || '').includes('after edit') && modalClosed) break; } expect(card.body).toContain('after edit'); ctrlZ(); for (let i = 0; i < 40; i++) { await sleep(100); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); if ((card?.body || '').includes('before edit')) break; } expect(card.body).toContain('before edit'); }); it('Ctrl+Z reverts due_date change', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_due__', body: '', color: '#38bdf8' }), }).then(r => r.json()); // Set a due date await fetch(`api/cards/${res.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ due_date: '2099-06-15' }), }); await window._reloadAll?.(); // Make sure state.cards has the new due_date before opening the // editor — the editor's snapshot for the undo entry reads from // state.cards.find() and would otherwise capture due_date=null. await waitForStateCard(res.id, c => c.due_date === '2099-06-15'); // Edit and change due date via editor const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); await sleep(150); click(document.querySelector('#ctx-items [data-action="edit"]')); await sleep(300); // Change due date by directly clearing it const dueInput = $('#inp-due-value'); if (dueInput) dueInput.value = ''; click('#modal-save-close'); await sleep(400); let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.due_date).toBe(null); // Undo should restore the due date. The undo PATCH + // _reloadAll is async — poll state.cards instead of a fixed // sleep so slow environments don't flake. ctrlZ(); card = await waitForStateCard(res.id, c => c.due_date === '2099-06-15', 3000); expect(card.due_date).toBe('2099-06-15'); }); }); describe('Undo: Card Delete', () => { afterEach(() => closeAll()); it('Ctrl+Z after delete recreates card with body and color', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_del__', body: 'restore me #testtag', color: '#fb7185' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); // Delete via context menu // First disable confirm-delete const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete'); localStorage.setItem('hyprcrd-confirm-delete', '0'); const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 })); await sleep(150); click(document.querySelector('#ctx-items [data-action="delete"]')); await sleep(400); // Verify deleted let cards = await fetch('api/cards').then(r => r.json()); expect(cards.some(c => c.id == res.id)).toBe(false); // Undo ctrlZ(); await sleep(500); // Card should be recreated (with new ID) cards = await fetch('api/cards').then(r => r.json()); const restored = cards.find(c => c.title === '__test_undo_del__'); expect(restored).toExist(); expect(restored.body).toContain('restore me'); expect(restored.color).toBe('#fb7185'); // Restore confirm setting if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); else localStorage.removeItem('hyprcrd-confirm-delete'); }); it('Ctrl+Z after delete preserves parent_id', async () => { const parent = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_del_parent__', body: '', color: '#38bdf8' }), }).then(r => r.json()); const child = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_del_child__', body: '', color: '#38bdf8', parent_id: parent.id }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete'); localStorage.setItem('hyprcrd-confirm-delete', '0'); // Delete the child (it may not be directly visible, delete via API + push undo manually) // Simpler: delete via API and test if undo restores parent_id // Actually we need to use the context menu to get the undo pushed // The child might be inside a group view — let's just test via the undo module directly // Import undo and test directly const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../../js/modules/undo.js'); const snap = { ...child, parent_id: parent.id }; pushCardDelete('test-del', snap); await fetch(`api/cards/${child.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } }); // Undo await undo(); await sleep(300); const cards = await fetch('api/cards').then(r => r.json()); const restored = cards.find(c => c.title === '__test_undo_del_child__'); expect(restored).toExist(); expect(restored.parent_id).toBe(parent.id); if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); else localStorage.removeItem('hyprcrd-confirm-delete'); }); }); describe('Undo: Bucket Change', () => { afterEach(() => closeAll()); it('Ctrl+Z reverts bucket assignment via context menu', async () => { const bucket = await fetch('api/buckets', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ name: '__test_undo_bucket__' }), }).then(r => r.json()); const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_bkt__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; // Original: no bucket let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.bucket_id).toBe(null); // Assign bucket via context menu rightClick(cardEl); await sleep(150); click($$('#ctx-items [data-action="bucket"]')[0]); await sleep(100); const bktBtn = $$('#ctx-buckets [data-ctx-bucket]').find(b => b.dataset.ctxBucket == bucket.id); if (bktBtn) click(bktBtn); await sleep(300); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.bucket_id).toBe(bucket.id); // Undo ctrlZ(); await sleep(400); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.bucket_id).toBe(null); }); }); describe('Undo: Multiple Undos', () => { afterEach(() => closeAll()); it('multiple Ctrl+Z reverts in stack order', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_undo_multi__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; // Change 1: color to pink rightClick(cardEl); await sleep(150); click($$('#ctx-items [data-action="color"]')[0]); await sleep(100); click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]')); // Wait for state.cards to reflect the new color before doing the // *second* rightClick — otherwise the second action's contextmenu // handler reads the stale card (#38bdf8) from state.cards and // pushes the wrong undo snapshot, collapsing both undo entries // onto the original color. await waitForStateCard(res.id, c => c.color === '#fb7185'); // Change 2: color to green const cardEl2 = document.querySelector(`[data-id="${res.id}"]`); rightClick(cardEl2); await sleep(150); click($$('#ctx-items [data-action="color"]')[0]); await sleep(100); click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]')); await waitForStateCard(res.id, c => c.color === '#4ade80'); let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.color).toBe('#4ade80'); // Undo 1: back to pink ctrlZ(); await sleep(400); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.color).toBe('#fb7185'); // Undo 2: back to original blue ctrlZ(); await sleep(400); card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.color).toBe('#38bdf8'); }); }); describe('Undo: No-Op When Empty', () => { it('Ctrl+Z with empty stack does nothing', async () => { const { canUndo } = await import('../../js/modules/undo.js'); // Clear stack by undoing everything while (canUndo()) { ctrlZ(); await sleep(200); } // Wait for state to settle — previous undo operations can still be // resurrecting cards asynchronously after canUndo() has flipped to // false. Poll until two consecutive fetches agree on card count. let prev = -1, stable = 0; for (let i = 0; i < 30 && stable < 2; i++) { const cards = await fetch('api/cards').then(r => r.json()); if (cards.length === prev) stable++; else { stable = 0; prev = cards.length; } if (stable < 2) await sleep(100); } const cardsBefore = await fetch('api/cards').then(r => r.json()); ctrlZ(); await sleep(200); const cardsAfter = await fetch('api/cards').then(r => r.json()); expect(cardsAfter.length).toBe(cardsBefore.length); }); });