Files
hyprcrd/public/plugins/tests/card-join.test.js
T
thandClaude Opus 4.6 c8835825e7 test fixes: dialog selector, closeAll scope, copy filter, runner ?v=3
* card-interactions.test.js: group delete tests used `.dialog-ok`
  (class selector) but the button has `id="dialog-ok"`. querySelector
  returned null, the click was skipped, the dialog stayed open and
  blocked every downstream test. Fixed to getElementById.
* runner.js closeAll(): added #dialog, #bucket-super-popover (remove),
  and #gitea-overlay to the cleanup list. A failed test that leaves any
  of these open no longer poisons the rest of the suite.
* runner.js copy handler: when there are failures or flaky tests, the
  "copy" button now builds the clipboard text from lastResults —
  only failed + flaky lines (suite name, test name, timing, error,
  original error for retried). When everything passed, copies the
  full output as before. lastResults is set in renderResults().
* bucket-context.test.js: move-left test used `[data-bucket]` to find
  user bucket tabs, which misses super-bucket tiles (they carry
  `data-bucket-super`). Changed to `[data-bucket-drag]` which is on
  both regular and super-bucket tiles but not on fixed filters.
* context-menu.test.js: make-ticket pre-fill assertion now polls up
  to 2s for the title input to be populated (async openCreateForm
  repo-fetch was racing against the immediate assertion).
* card-join.test.js: clickJoin sleep bumped from 400ms → 800ms.
  joinSelectedCards does 3+ serial API calls (fetch bodies, PATCH
  winner, N×DELETE losers, _reloadAll) which sometimes exceeded the
  old 400ms window on slow runs.
* Runner cache: RUNNER_V bumped from ?v=2 → ?v=3 in loader.js + all
  42 test files so the browser picks up the new runner.js content
  (copy handler, closeAll, retry progress bar) without a hard reload.

super-buckets: remember last selection per group

* buckets.js: module-scope lastSuperSelection Map tracks the last
  active member per super-bucket parent. Updated in renderBar()
  whenever activeBucket matches a group member. tileFor() uses it
  as a fallback when the user is on a different bucket — the tile
  shows the last-picked child's name and color instead of reverting
  to the parent. The dropdown popover also highlights the last
  selection when no member is currently active.

chore: bump version to 0.9.11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:05:38 +02:00

246 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=3';
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);
// joinSelectedCards does: fetch full bodies → PATCH winner → N × DELETE
// losers → _reloadAll. That's 3+ serial API calls and a full board
// re-render, so 400ms was sometimes too tight on slower runs.
await sleep(800);
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();
});
});