import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=4'; import { state } from '../../js/modules/state.js'; describe('Card: Tag Operations', () => { afterEach(() => closeAll()); it('tag chips visible on cards (if setting on)', () => { if (localStorage.getItem('hyprcrd-show-tags') === '0') return; const card = $('#board [data-id]'); if (!card) return; const tags = card.querySelectorAll('.tag-chip'); // May or may not have tags, just verify no crash expect(tags).toExist(); }); it('tag click filters board', async () => { const seg = document.querySelector('#board .tag-seg[data-tag]'); if (!seg) return; const tag = seg.dataset.tag; seg.click(); await sleep(200); expect(state.activeTags).toContain(tag); // Reset seg.click(); await sleep(100); }); it('tag right-click removes tag from card', async () => { // Create a card with a tag const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_tag_rm__', body: 'text #testtag123', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); const tagSeg = cardEl?.querySelector('.tag-seg[data-tag="testtag123"]'); if (tagSeg) { tagSeg.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, preventDefault: true })); await sleep(300); // Verify tag removed const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json()); const card = updated.find(c => c.id == res.id); expect(card.tags).toBeFalsy(); } }); }); describe('Card: Due Date & Done', () => { afterEach(() => closeAll()); it('card with due_date shows due pill', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_due_pill__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await fetch(`api/cards/${res.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ due_date: '2099-12-31' }), }); state.activeBucket = 'all'; await window._reloadAll?.(); // Poll for the card to appear in the DOM instead of a fixed sleep let cardEl = null; for (let i = 0; i < 40; i++) { cardEl = document.querySelector(`[data-id="${res.id}"]`); if (cardEl) break; await sleep(50); } const pill = cardEl?.querySelector('.due-pill'); expect(pill).toExist(); expect(pill.textContent).toContain('2099-12-31'); }); it('card with #done tag shows done badge', async () => { if (localStorage.getItem('hyprcrd-show-done-badge') === '0') return; if (state.hideDone || state.hideTagged) return; const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_done_badge__', body: '#done', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); const badge = cardEl?.querySelector('.card-done-badge'); expect(badge).toExist(); }); it('overdue card has danger border', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_overdue__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await fetch(`api/cards/${res.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ due_date: '2020-01-01' }), }); state.activeBucket = 'all'; await window._reloadAll?.(); let cardEl = null; for (let i = 0; i < 40; i++) { cardEl = document.querySelector(`[data-id="${res.id}"]`); if (cardEl) break; await sleep(50); } expect(cardEl).toExist(); expect(cardEl.style.borderBottom).toContain('var(--t-danger)'); }); }); describe('Card: Context Menu Actions', () => { afterEach(() => closeAll()); it('favorite toggles', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(150); const favBtn = $$('#ctx-items [data-action="favorite"]')[0]; const wasFav = favBtn?.textContent.toLowerCase().includes('unfavorite'); click(favBtn); await sleep(200); // Open menu again to check toggled state rightClick(card); await sleep(150); const favBtn2 = $$('#ctx-items [data-action="favorite"]')[0]; const isFav = favBtn2?.textContent.toLowerCase().includes('unfavorite'); expect(isFav).toBe(!wasFav); // Toggle back click(favBtn2); await sleep(100); }); it('mark done toggles #done on cards without due_date', async () => { // After clicking, the card has #done — if either filter is on it would // disappear from the board before the second right-click can find it. if (state.hideDone || state.hideTagged) return; // Create a card with no due_date — Mark done used to be hidden in this case const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_mark_done__', body: 'plain text', 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); // The action must always be present, even with no due_date const doneBtn = $$('#ctx-items [data-action="due-done"]')[0]; expect(doneBtn).toExist(); expect(doneBtn.textContent.toLowerCase()).toContain('mark done'); click(doneBtn); // Poll until #done is persisted to the DB let updated; for (let i = 0; i < 40; i++) { await sleep(100); updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json()); if ((updated[0]?.tags || '').includes('done')) break; } expect((updated[0].body || '').toLowerCase()).toContain('#done'); expect(updated[0].tags || '').toContain('done'); // Wait for the board to re-render with the updated card data for (let i = 0; i < 20; i++) { const card = window.state?.cards?.find(c => c.id == res.id); if (card && (card.tags || '').includes('done')) break; await sleep(100); } // Re-open menu — the entry should now flip to "Unmark done" rightClick(document.querySelector(`[data-id="${res.id}"]`)); await sleep(200); const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0]; expect(doneBtn2.textContent.toLowerCase()).toContain('unmark'); click(doneBtn2); await sleep(300); updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json()); expect((updated[0].body || '').toLowerCase().includes('#done')).toBe(false); }); it('color submenu changes card color', async () => { const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_color_ctx__', 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 color action to open submenu click($$('#ctx-items [data-action="color"]')[0]); await sleep(100); // Pick a different color const colorBtn = document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'); if (colorBtn) { click(colorBtn); await sleep(300); const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json()); expect(updated.find(c => c.id == res.id).color).toBe('#fb7185'); } }); }); describe('Card: Bucket Assignment', () => { afterEach(() => closeAll()); it('card moves to bucket via context menu', async () => { // Create bucket and card const bucket = await fetch('api/buckets', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ name: '__test_ctx_bucket__' }), }).then(r => r.json()); const card = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_ctx_move__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${card.id}"]`); if (!cardEl) return; rightClick(cardEl); await sleep(150); // Click bucket action click($$('#ctx-items [data-action="bucket"]')[0]); await sleep(100); // Click the test bucket in submenu const bucketBtn = $$('#ctx-buckets [data-ctx-bucket]').find(b => b.dataset.ctxBucket == bucket.id); if (bucketBtn) { click(bucketBtn); await sleep(300); const updated = await fetch(`api/cards?ids=${card.id}`).then(r => r.json()); expect(updated.find(c => c.id == card.id).bucket_id).toBe(bucket.id); } }); }); describe('Card: Grouping', () => { afterEach(() => closeAll()); it('cards can be grouped via API', async () => { const parent = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_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_child__', body: '', color: '#38bdf8' }), }).then(r => r.json()); await fetch(`api/cards/${child.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ parent_id: parent.id }), }); const all = await fetch(`api/cards?ids=${child.id}`).then(r => r.json()); expect(all.find(c => c.id == child.id).parent_id).toBe(parent.id); }); it('child card hidden on board (shown under parent)', async () => { const parent = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_grp_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_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }), }).then(r => r.json()); state.activeBucket = 'all'; await window._reloadAll?.(); let parentEl = null; for (let i = 0; i < 40; i++) { parentEl = document.querySelector(`[data-id="${parent.id}"]`); if (parentEl) break; await sleep(50); } expect(parentEl).toExist(); }); }); describe('Card: Group Delete', () => { afterEach(() => closeAll()); it('deleting a group removes parent AND all children', async () => { // Disable confirm-delete so the non-group path doesn't block, // but the group delete ALWAYS confirms regardless of this setting. const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete'); localStorage.setItem('hyprcrd-confirm-delete', '0'); const parent = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_grpdel_parent__', body: '', color: '#38bdf8' }), }).then(r => r.json()); const child1 = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_grpdel_child1__', body: '', color: '#38bdf8', parent_id: parent.id }), }).then(r => r.json()); const child2 = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_grpdel_child2__', body: '', color: '#38bdf8', parent_id: parent.id }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); // The group parent should be on the board const parentEl = document.querySelector(`[data-id="${parent.id}"]`); if (!parentEl) { // Restore and skip if parent not visible (e.g. filter state) if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); else localStorage.removeItem('hyprcrd-confirm-delete'); return; } // Right-click → delete. The group delete dialog always shows // regardless of the confirm-delete setting. We need to auto- // confirm it. The showDialog function exposes the dialog element // as #dialog; the OK button is .dialog-ok. rightClick(parentEl); await sleep(150); click($$('#ctx-items [data-action="delete"]')[0]); await sleep(200); // The confirmation dialog should be showing the child list const dialog = document.getElementById('dialog'); if (dialog?.classList.contains('open')) { const msg = dialog.querySelector('#dialog-msg'); // Dialog should mention both children expect(msg.textContent).toContain('__test_grpdel_child1__'); expect(msg.textContent).toContain('__test_grpdel_child2__'); // Confirm the delete const okBtn = document.getElementById('dialog-ok'); if (okBtn) click(okBtn); } // Poll until all three cards are gone (group delete = N serial DELETEs) let remaining; for (let i = 0; i < 40; i++) { await sleep(100); remaining = await fetch('api/cards').then(r => r.json()); const stillThere = remaining.some(c => c.id === parent.id || c.id === child1.id || c.id === child2.id); if (!stillThere) break; } expect(remaining.some(c => c.id === parent.id)).toBe(false); expect(remaining.some(c => c.id === child1.id)).toBe(false); expect(remaining.some(c => c.id === child2.id)).toBe(false); if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); else localStorage.removeItem('hyprcrd-confirm-delete'); }); it('Ctrl+Z after group delete restores parent + children', async () => { const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete'); localStorage.setItem('hyprcrd-confirm-delete', '0'); const parent = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_grpundo_parent__', body: 'parent body', color: '#fb7185' }), }).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_grpundo_child__', body: 'child body', color: '#38bdf8', parent_id: parent.id }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const parentEl = document.querySelector(`[data-id="${parent.id}"]`); if (!parentEl) { if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); else localStorage.removeItem('hyprcrd-confirm-delete'); return; } // Delete the group rightClick(parentEl); await sleep(150); click($$('#ctx-items [data-action="delete"]')[0]); await sleep(200); const dialog = document.getElementById('dialog'); if (dialog?.classList.contains('open')) { const okBtn = document.getElementById('dialog-ok'); if (okBtn) click(okBtn); await sleep(500); } // Confirm both are gone let cards = await fetch('api/cards').then(r => r.json()); expect(cards.some(c => c.id === parent.id)).toBe(false); expect(cards.some(c => c.id === child.id)).toBe(false); // Undo document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })); await sleep(600); // Parent + child should be back (new ids, find by title) cards = await fetch('api/cards').then(r => r.json()); const restoredParent = cards.find(c => c.title === '__test_grpundo_parent__'); const restoredChild = cards.find(c => c.title === '__test_grpundo_child__'); expect(restoredParent).toExist(); expect(restoredChild).toExist(); // Child should point at the restored parent expect(restoredChild.parent_id).toBe(restoredParent.id); if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); else localStorage.removeItem('hyprcrd-confirm-delete'); }); });