Files
hyprcrd/public/plugins/tests/super-buckets.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

270 lines
11 KiB
JavaScript

import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=3';
import { state } from '../../js/modules/state.js';
// Helper: create a bucket with the given name and return its row.
async function makeBucket(name) {
return fetch('api/buckets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ name }),
}).then(r => r.json());
}
async function patchBucket(id, body) {
const res = await fetch(`api/buckets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
async function deleteBucket(id) {
return fetch(`api/buckets/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf() },
}).then(r => r.json());
}
async function getBuckets() {
return fetch('api/buckets').then(r => r.json());
}
describe('Super-buckets: API parent_id', () => {
afterEach(async () => {
// These tests don't touch state.activeBucket but the bar-rendering
// suite below does — keep cleanup symmetric so reordering tests
// doesn't introduce a regression.
state.activeBucket = 'all';
await closeAll();
});
it('PATCH parent_id nests one bucket under another', async () => {
const parent = await makeBucket('__sb_parent_a__');
const child = await makeBucket('__sb_child_a__');
const res = await patchBucket(child.id, { parent_id: parent.id });
expect(res.status).toBe(200);
const all = await getBuckets();
const found = all.find(b => b.id === child.id);
expect(found.parent_id).toBe(parent.id);
});
it('PATCH parent_id = null un-nests', async () => {
const parent = await makeBucket('__sb_parent_b__');
const child = await makeBucket('__sb_child_b__');
await patchBucket(child.id, { parent_id: parent.id });
const res = await patchBucket(child.id, { parent_id: null });
expect(res.status).toBe(200);
const all = await getBuckets();
const found = all.find(b => b.id === child.id);
expect(found.parent_id).toBeFalsy();
});
it('refuses self as parent', async () => {
const b = await makeBucket('__sb_self__');
const res = await patchBucket(b.id, { parent_id: b.id });
expect(res.status).toBe(400);
expect(res.body.error).toContain('self');
});
it('refuses level 2 — bucket with own children cannot become a child', async () => {
const grandparent = await makeBucket('__sb_grand__');
const middle = await makeBucket('__sb_middle__');
const leaf = await makeBucket('__sb_leaf__');
// middle becomes a parent of leaf
await patchBucket(leaf.id, { parent_id: middle.id });
// Now try to make middle a child of grandparent — would create level 2
const res = await patchBucket(middle.id, { parent_id: grandparent.id });
expect(res.status).toBe(400);
expect(res.body.error.toLowerCase()).toContain('children');
});
it('refuses nesting under a bucket that is itself a child', async () => {
const parent = await makeBucket('__sb_parent_c__');
const child = await makeBucket('__sb_child_c__');
const other = await makeBucket('__sb_other_c__');
await patchBucket(child.id, { parent_id: parent.id });
// Try to nest `other` under `child` — child is already nested
const res = await patchBucket(other.id, { parent_id: child.id });
expect(res.status).toBe(400);
expect(res.body.error.toLowerCase()).toContain('child');
});
it('DELETE on a parent un-nests its children back to top-level', async () => {
const parent = await makeBucket('__sb_parent_d__');
const c1 = await makeBucket('__sb_child_d1__');
const c2 = await makeBucket('__sb_child_d2__');
await patchBucket(c1.id, { parent_id: parent.id });
await patchBucket(c2.id, { parent_id: parent.id });
await deleteBucket(parent.id);
const all = await getBuckets();
const f1 = all.find(b => b.id === c1.id);
const f2 = all.find(b => b.id === c2.id);
expect(f1).toExist();
expect(f2).toExist();
expect(f1.parent_id).toBeFalsy();
expect(f2.parent_id).toBeFalsy();
});
it('export → import preserves parent_id', async () => {
const parent = await makeBucket('__sb_parent_e__');
const child = await makeBucket('__sb_child_e__');
await patchBucket(child.id, { parent_id: parent.id });
// Round-trip through export → replace-import
const blob = await fetch('api/export').then(r => r.blob());
const form = new FormData();
form.append('file', blob, 'roundtrip.zip');
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
const all = await getBuckets();
const p = all.find(b => b.name === '__sb_parent_e__');
const c = all.find(b => b.name === '__sb_child_e__');
expect(p).toExist();
expect(c).toExist();
expect(c.parent_id).toBe(p.id);
});
});
describe('Super-buckets: Bar rendering', () => {
afterEach(async () => {
// Reset activeBucket — several tests in this suite set it to a
// child id that wouldn't otherwise survive the run. Without this
// reset, downstream tests that create cards with bucket_id=null
// can't find them on the board (the activeBucket filter excludes
// them) and start failing in nondeterministic order.
state.activeBucket = 'all';
document.getElementById('bucket-super-popover')?.remove();
await closeAll();
});
it('a parent with one child renders a single super-tile in the bar', async () => {
const parent = await makeBucket('__sb_render_parent__');
const child = await makeBucket('__sb_render_child__');
await patchBucket(child.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
// The super-tile carries data-bucket-super=<parent.id>; the child is
// not rendered directly in the bar.
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
expect(superTile).toExist();
const childTile = document.querySelector(`#bucket-bar [data-bucket="${child.id}"]:not(#bucket-super-popover [data-bucket])`);
expect(childTile).toBe(null);
});
it('super-tile shows the parent name when no child is active', async () => {
const parent = await makeBucket('__sb_label_parent__');
const child = await makeBucket('__sb_label_child__');
await patchBucket(child.id, { parent_id: parent.id });
// Reset active filter so the parent is the default shown member.
state.activeBucket = 'all';
await window._reloadAll?.();
await sleep(200);
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
expect(superTile.textContent).toContain('__sb_label_parent__');
});
it('super-tile relabels to the active child name when a child is selected', async () => {
const parent = await makeBucket('__sb_active_parent__');
const child = await makeBucket('__sb_active_child__');
await patchBucket(child.id, { parent_id: parent.id });
state.activeBucket = String(child.id);
await window._reloadAll?.();
await sleep(200);
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
expect(superTile.textContent).toContain('__sb_active_child__');
});
it('clicking the super-tile opens a popover with parent + child entries', async () => {
const parent = await makeBucket('__sb_pop_parent__');
const child1 = await makeBucket('__sb_pop_child1__');
const child2 = await makeBucket('__sb_pop_child2__');
await patchBucket(child1.id, { parent_id: parent.id });
await patchBucket(child2.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
await sleep(100);
const popover = document.getElementById('bucket-super-popover');
expect(popover).toExist();
// Parent + 2 children = 3 entries
const entries = popover.querySelectorAll('[data-bucket]');
expect(entries.length).toBe(3);
// All three names appear
const text = popover.textContent;
expect(text).toContain('__sb_pop_parent__');
expect(text).toContain('__sb_pop_child1__');
expect(text).toContain('__sb_pop_child2__');
});
it('clicking a child entry in the popover sets activeBucket', async () => {
const parent = await makeBucket('__sb_act_parent__');
const child = await makeBucket('__sb_act_child__');
await patchBucket(child.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
await sleep(100);
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
expect(childEntry).toExist();
click(childEntry);
await sleep(150);
expect(String(state.activeBucket)).toBe(String(child.id));
});
it('clicking the super-tile twice toggles the popover closed', async () => {
const parent = await makeBucket('__sb_toggle_parent__');
const child = await makeBucket('__sb_toggle_child__');
await patchBucket(child.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
await sleep(100);
expect(document.getElementById('bucket-super-popover')).toExist();
click(superTile);
await sleep(100);
expect(document.getElementById('bucket-super-popover')).toBe(null);
});
it('regular bucket (no children) keeps its plain data-bucket tile', async () => {
const plain = await makeBucket('__sb_plain__');
await window._reloadAll?.();
await sleep(200);
const tile = document.querySelector(`#bucket-bar [data-bucket="${plain.id}"]`);
expect(tile).toExist();
// Plain tile is NOT a super-tile
expect(tile.classList.contains('bucket-super')).toBe(false);
});
});
describe('Super-buckets: Drop cursor', () => {
afterEach(() => closeAll());
it('#bucket-drop-cursor element exists in the bar', async () => {
// Force a fresh render so the cursor element is appended
await window._reloadAll?.();
await sleep(100);
const cursor = document.querySelector('#bucket-bar #bucket-drop-cursor');
expect(cursor).toExist();
// Hidden by default
expect(cursor.style.display === 'none' || cursor.style.display === '').toBe(true);
});
});