import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=2'; import { state } from '../../js/modules/state.js'; async function makeCard(title, body = '') { return fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title, body, color: '#38bdf8' }), }).then(r => r.json()); } async function getCard(id) { const all = await fetch(`api/cards?ids=${id}`).then(r => r.json()); return all.find(c => c.id == id); } function selectIds(ids, { bulk = false } = {}) { state.selectedIds.clear(); state.selectionWasBulk = bulk; for (const id of ids) state.selectedIds.add(String(id)); } async function clickJoin(rightClickTargetEl) { rightClick(rightClickTargetEl); await sleep(150); const joinBtn = $$('#ctx-items [data-action="join"]')[0]; if (!joinBtn) return false; click(joinBtn); await sleep(400); return true; } describe('Card: Join — context menu visibility', () => { afterEach(async () => { state.selectedIds.clear(); state.selectionWasBulk = false; await closeAll(); }); it('shows Join action when ≥2 cards are selected and right-clicked card is one of them', async () => { const a = await makeCard('__join_vis_a__'); const b = await makeCard('__join_vis_b__'); await window._reloadAll?.(); await sleep(200); const aEl = document.querySelector(`[data-id="${a.id}"]`); if (!aEl) return; selectIds([a.id, b.id]); rightClick(aEl); await sleep(150); const joinBtn = $$('#ctx-items [data-action="join"]')[0]; expect(joinBtn).toExist(); expect(joinBtn.textContent.toLowerCase()).toContain('join'); expect(joinBtn.textContent).toContain('2'); }); it('does NOT show Join with only one card selected', async () => { const a = await makeCard('__join_vis_solo__'); await window._reloadAll?.(); await sleep(200); const aEl = document.querySelector(`[data-id="${a.id}"]`); if (!aEl) return; selectIds([a.id]); rightClick(aEl); await sleep(150); const joinBtn = $$('#ctx-items [data-action="join"]')[0]; expect(joinBtn).toBe(undefined); }); it('does NOT show Join when right-clicked card is not part of the selection', async () => { const a = await makeCard('__join_vis_outside_a__'); const b = await makeCard('__join_vis_outside_b__'); const c = await makeCard('__join_vis_outside_c__'); await window._reloadAll?.(); await sleep(200); const cEl = document.querySelector(`[data-id="${c.id}"]`); if (!cEl) return; selectIds([a.id, b.id]); rightClick(cEl); await sleep(150); const joinBtn = $$('#ctx-items [data-action="join"]')[0]; expect(joinBtn).toBe(undefined); }); }); describe('Card: Join — sequential selection (first wins)', () => { afterEach(async () => { state.selectedIds.clear(); state.selectionWasBulk = false; await closeAll(); }); it('first ctrl-clicked card becomes the winner; others are deleted', async () => { const first = await makeCard('__join_seq_first__', 'body of first'); const second = await makeCard('__join_seq_second__', 'body of second'); const third = await makeCard('__join_seq_third__', 'body of third'); await window._reloadAll?.(); await sleep(200); const firstEl = document.querySelector(`[data-id="${first.id}"]`); if (!firstEl) return; selectIds([first.id, second.id, third.id], { bulk: false }); const ok = await clickJoin(firstEl); if (!ok) return; // Winner survives const survivor = await getCard(first.id); expect(survivor).toExist(); expect(survivor.title).toBe('__join_seq_first__'); // Losers deleted expect(await getCard(second.id)).toBe(undefined); expect(await getCard(third.id)).toBe(undefined); }); it('merged body lists every original title at the top', async () => { const a = await makeCard('__join_titles_a__', 'aaa'); const b = await makeCard('__join_titles_b__', 'bbb'); const c = await makeCard('__join_titles_c__', 'ccc'); await window._reloadAll?.(); await sleep(200); const aEl = document.querySelector(`[data-id="${a.id}"]`); if (!aEl) return; selectIds([a.id, b.id, c.id], { bulk: false }); const ok = await clickJoin(aEl); if (!ok) return; const survivor = await getCard(a.id); expect(survivor.body).toContain('__join_titles_a__'); expect(survivor.body).toContain('__join_titles_b__'); expect(survivor.body).toContain('__join_titles_c__'); // All three bodies preserved too expect(survivor.body).toContain('aaa'); expect(survivor.body).toContain('bbb'); expect(survivor.body).toContain('ccc'); // Title list comes before the body content const titlePos = survivor.body.indexOf('__join_titles_b__'); const bodyPos = survivor.body.indexOf('bbb'); expect(titlePos).toBeLessThan(bodyPos); }); it('selection is cleared after a successful join', async () => { const a = await makeCard('__join_clearsel_a__'); const b = await makeCard('__join_clearsel_b__'); await window._reloadAll?.(); await sleep(200); const aEl = document.querySelector(`[data-id="${a.id}"]`); if (!aEl) return; selectIds([a.id, b.id], { bulk: false }); const ok = await clickJoin(aEl); if (!ok) return; expect(state.selectedIds.size).toBe(0); expect(state.selectionWasBulk).toBe(false); }); }); describe('Card: Join — bulk selection (longest title wins)', () => { afterEach(async () => { state.selectedIds.clear(); state.selectionWasBulk = false; await closeAll(); }); it('longest-title card becomes the winner regardless of insertion order', async () => { const short = await makeCard('__jbulk_x__', 'x body'); const longer = await makeCard('__jbulk_loooooong_winner_title__', 'l body'); const medium = await makeCard('__jbulk_med_xx__', 'm body'); await window._reloadAll?.(); await sleep(200); const shortEl = document.querySelector(`[data-id="${short.id}"]`); if (!shortEl) return; // Insert in order short, longer, medium — but mark as bulk so the // join code uses longest-title strategy. selectIds([short.id, longer.id, medium.id], { bulk: true }); const ok = await clickJoin(shortEl); if (!ok) return; // The longest-titled card should be the survivor const survivor = await getCard(longer.id); expect(survivor).toExist(); expect(survivor.title).toBe('__jbulk_loooooong_winner_title__'); // The other two are deleted expect(await getCard(short.id)).toBe(undefined); expect(await getCard(medium.id)).toBe(undefined); // Survivor body still has all titles expect(survivor.body).toContain('__jbulk_x__'); expect(survivor.body).toContain('__jbulk_med_xx__'); }); }); describe('Card: Join — undo', () => { afterEach(async () => { state.selectedIds.clear(); state.selectionWasBulk = false; await closeAll(); }); it('Ctrl+Z reverts a join: winner body restored, losers recreated', async () => { const winner = await makeCard('__join_undo_winner__', 'original winner body'); const loser = await makeCard('__join_undo_loser__', 'original loser body'); await window._reloadAll?.(); await sleep(200); const winnerEl = document.querySelector(`[data-id="${winner.id}"]`); if (!winnerEl) return; selectIds([winner.id, loser.id], { bulk: false }); const ok = await clickJoin(winnerEl); if (!ok) return; // Sanity: loser is gone, winner has merged body expect(await getCard(loser.id)).toBe(undefined); let survivor = await getCard(winner.id); expect(survivor.body).toContain('__join_undo_loser__'); // Undo via global Ctrl+Z document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })); await sleep(400); // Winner body restored to original survivor = await getCard(winner.id); expect(survivor.body).toBe('original winner body'); // A loser card was recreated with the same title + body // (its id will differ — find by title since the original is gone) const all = await fetch('api/cards').then(r => r.json()); const restored = all.find(c => c.title === '__join_undo_loser__'); expect(restored).toExist(); }); });