Updates help screen with all undocumented features. Fixes F1 blocked by input suppression filter (allow function keys). Stabilizes card-join tests with poll loop instead of fixed sleep. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
9.3 KiB
JavaScript
254 lines
9.3 KiB
JavaScript
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=4';
|
||
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, winnerId) {
|
||
rightClick(rightClickTargetEl);
|
||
await sleep(150);
|
||
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||
if (!joinBtn) return false;
|
||
click(joinBtn);
|
||
// joinSelectedCards does: fetch full bodies → PATCH winner → N × DELETE
|
||
// losers → _reloadAll. Poll for the winner to have merged body.
|
||
for (let i = 0; i < 30; i++) {
|
||
await sleep(100);
|
||
if (winnerId) {
|
||
const c = await getCard(winnerId);
|
||
if (c?.body?.includes('## ')) return true;
|
||
} else {
|
||
await sleep(600);
|
||
return true;
|
||
}
|
||
}
|
||
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, first.id);
|
||
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, a.id);
|
||
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, a.id);
|
||
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, longer.id);
|
||
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, winner.id);
|
||
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();
|
||
});
|
||
});
|