Files
hyprcrd/public/plugins/tests/undo.test.js
T
2026-04-14 19:32:17 +02:00

376 lines
16 KiB
JavaScript

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');
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);
if ((card?.body || '').includes('after edit')) 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
ctrlZ();
await sleep(400);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
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);
});
});