Files
hyprcrd/public/plugins/tests/undo.test.js
T
thandClaude Opus 4.6 9b4869812f join: merge multi-selected cards into one (right-click + toolbar)
Right-click on a card that's part of a ≥2-card selection now offers
"Join N cards" at the top of the context menu, and the same action
sits next to "Group" in the bottom select-bar.

Title strategy:
* sequential ctrl-click selection → first selected wins
* bulk gesture (rubber-band) → longest title wins

The bulk vs sequential distinction is tracked via a new
`state.selectionWasBulk` flag set in board.js when the rubber-band
becomes active and cleared in clearSelection() and after a successful
join. ctrl-clicks alone don't touch it, so bulk + ctrl-click hybrid
selections stay bulk.

Body format: list of every original title as `## heading` lines, then
each card's body separated by `---`. The winner keeps its color, tags,
bucket, due_date, parent_id; only the body is rewritten. Losers are
deleted via DELETE /api/cards/{id}.

Undo: a single pushUndo entry restores the winner's original body and
recreates every loser via POST + due_date PATCH (since POST doesn't
take due_date). Ctrl+Z reverts the whole join.

Implementation:
* state.js: new selectionWasBulk boolean in state.
* board.js: rubber-band onMove sets state.selectionWasBulk = true on
  first activation; clearSelection clears it. New select-join click
  handler dynamic-imports joinSelectedCards from context-menu.js to
  keep the dependency lazy.
* context-menu.js: joinSelectedCards is now exported. Inserts
  data-action="join" into the menu when the right-clicked card is in
  a ≥2-card selection. Action handler dispatches to joinSelectedCards
  before falling through to the regular if-chain so it can early-return.
* index.php: new #select-join button in #select-bar with ph-arrows-merge
  icon, sits between Group and Bucket.

Tests (public/plugins/tests/card-join.test.js, registered in tests.js):
* visibility: shows for ≥2 selected with right-clicked card in the
  selection; hidden for solo selection; hidden when right-click target
  isn't selected.
* sequential winner: first ctrl-clicked card survives, others deleted.
* merged body lists every title at the top and contains every body.
* selection cleared after a successful join.
* bulk winner: longest title survives regardless of insertion order.
* undo: Ctrl+Z restores winner body + recreates losers.

undo.test.js race-fix:
* New waitForStateCard(id, predicate, timeout=2000) helper polls
  state.cards every 30ms until the in-memory state reflects the
  expected change. Replaces fragile fixed `sleep(300)` waits between
  chained user actions where the second action reads from state.cards
  before the first action's _reloadAll has landed.
* Multi-undo color test: between the two color changes, wait for
  state.cards to actually contain the pink color before doing the
  second rightClick. Without this the second action's contextmenu
  handler reads the stale card and pushes the wrong undo snapshot,
  collapsing both undo entries onto the original color.
* Due_date undo test: after PATCHing due_date directly via API, wait
  for state.cards to reflect it before opening the editor — the
  editor's snapshot for the undo entry reads from state.cards.find()
  and would otherwise capture due_date=null.

chore: bump version to 0.9.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:25:42 +02:00

361 lines
15 KiB
JavaScript

import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=2';
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');
await sleep(400);
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
expect(card.body).toContain('after edit');
ctrlZ();
await sleep(400);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
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);
}
// Now Ctrl+Z should be harmless
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);
});
});