Files
hyprcrd/public/plugins/tests/super-buckets.test.js
T
thandClaude Opus 4.6 527305355c super-buckets: split tab click (activate) from caret click (dropdown)
Clicking the super-bucket tab now activates the last-shown child
directly. Only the caret arrow on the right opens the dropdown popover.
Caret has its own hit area with accent hover highlight.

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

290 lines
12 KiB
JavaScript

import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
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 caret 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;
const caret = superTile.querySelector('[data-super-caret]');
expect(caret).toExist();
click(caret);
await sleep(100);
const popover = document.getElementById('bucket-super-popover');
expect(popover).toExist();
const entries = popover.querySelectorAll('[data-bucket]');
expect(entries.length).toBe(3);
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;
const caret = superTile.querySelector('[data-super-caret]');
click(caret);
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 activates the shown bucket', async () => {
const parent = await makeBucket('__sb_direct_parent__');
const child = await makeBucket('__sb_direct_child__');
await patchBucket(child.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
state.activeBucket = 'all';
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
await sleep(150);
const shownId = superTile.dataset.superShown;
expect(String(state.activeBucket)).toBe(String(shownId));
expect(document.getElementById('bucket-super-popover')).toBe(null);
});
it('clicking the caret 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;
const caret = superTile.querySelector('[data-super-caret]');
click(caret);
await sleep(100);
expect(document.getElementById('bucket-super-popover')).toExist();
click(caret);
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);
});
});