Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
427 lines
19 KiB
JavaScript
427 lines
19 KiB
JavaScript
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);
|
|
}
|
|
|
|
// Poll until deletion lands (can take a while on slow machines with
|
|
// confirm-dialog + two DELETE calls chained).
|
|
let cards;
|
|
const start = Date.now();
|
|
while (Date.now() - start < 4000) {
|
|
cards = await fetch('api/cards').then(r => r.json());
|
|
const stillThere = cards.some(c => c.id === parent.id || c.id === child.id);
|
|
if (!stillThere) break;
|
|
await sleep(100);
|
|
}
|
|
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');
|
|
});
|
|
});
|