import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=2'; 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=; 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); }); });