Files
hyprcrd/public/plugins/tests/card-interactions.test.js
T
thandClaude Opus 4.6 fe3399a44b tests: in-run progress strip + cache-bust runner.js across files
* runner.js: shows a 30px fixed-top progress strip during runs (label
  "test x/total:", current suite/test name with ellipsis on the left,
  fixed 50%-wide progress fill on the right that turns danger-red on
  the first failure). The DOM node is created in loader.js so it appears
  immediately on shortcut press; runner.js only updates it. The fill
  width is fixed at flex:0 0 50% so it doesn't jiggle as the test name
  changes length. Suites now live on window._testSuites as a defensive
  fallback so two runner instances stay in sync.

* Cache invalidation: every test file's `from './runner.js'` is now
  `from './runner.js?v=2'`, and loader.js imports the runner with the
  same RUNNER_V suffix. Same URL → one module instance → suite
  registration and runAll see the same array. Bumping RUNNER_V (in
  loader.js + every test file in lock-step) is the way to force a
  re-fetch when runner.js changes.

* loader.js: ensureProgressBar() draws the strip via setProperty(...,
  'important') with z-index 2147483647 attached to documentElement so
  nothing in user CSS, scanline overlay (z 9999) or modals can occlude
  it. Auto-run path (?test=1) also pre-renders the strip.

chore: bump version to 0.9.1

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

272 lines
12 KiB
JavaScript

import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=2';
import { state } from '../../js/modules/state.js';
describe('Card: Tag Operations', () => {
afterEach(() => closeAll());
it('tag chips visible on cards (if setting on)', () => {
if (localStorage.getItem('hyprcrd-show-tags') === '0') return;
const card = $('#board [data-id]');
if (!card) return;
const tags = card.querySelectorAll('.tag-chip');
// May or may not have tags, just verify no crash
expect(tags).toExist();
});
it('tag click filters board', async () => {
const seg = document.querySelector('#board .tag-seg[data-tag]');
if (!seg) return;
const tag = seg.dataset.tag;
seg.click();
await sleep(200);
expect(state.activeTags).toContain(tag);
// Reset
seg.click();
await sleep(100);
});
it('tag right-click removes tag from card', async () => {
// Create a card with a tag
const res = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_tag_rm__', body: 'text #testtag123', color: '#38bdf8' }),
}).then(r => r.json());
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
const tagSeg = cardEl?.querySelector('.tag-seg[data-tag="testtag123"]');
if (tagSeg) {
tagSeg.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, preventDefault: true }));
await sleep(300);
// Verify tag removed
const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
const card = updated.find(c => c.id == res.id);
expect(card.tags).toBeFalsy();
}
});
});
describe('Card: Due Date & Done', () => {
afterEach(() => closeAll());
it('card with due_date shows due pill', async () => {
const res = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_due_pill__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
await fetch(`api/cards/${res.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ due_date: '2099-12-31' }),
});
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
const pill = cardEl?.querySelector('.due-pill');
expect(pill).toExist();
expect(pill.textContent).toContain('2099-12-31');
});
it('card with #done tag shows done badge', async () => {
if (localStorage.getItem('hyprcrd-show-done-badge') === '0') return;
if (state.hideDone || state.hideTagged) return;
const res = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_done_badge__', body: '#done', color: '#38bdf8' }),
}).then(r => r.json());
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
const badge = cardEl?.querySelector('.card-done-badge');
expect(badge).toExist();
});
it('overdue card has danger border', async () => {
const res = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_overdue__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
await fetch(`api/cards/${res.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ due_date: '2020-01-01' }),
});
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
expect(cardEl).toExist();
// Overdue cards get danger border via inline style
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
});
});
describe('Card: Context Menu Actions', () => {
afterEach(() => closeAll());
it('favorite toggles', async () => {
const card = $('#board [data-id]');
if (!card) return;
rightClick(card);
await sleep(150);
const favBtn = $$('#ctx-items [data-action="favorite"]')[0];
const wasFav = favBtn?.textContent.toLowerCase().includes('unfavorite');
click(favBtn);
await sleep(200);
// Open menu again to check toggled state
rightClick(card);
await sleep(150);
const favBtn2 = $$('#ctx-items [data-action="favorite"]')[0];
const isFav = favBtn2?.textContent.toLowerCase().includes('unfavorite');
expect(isFav).toBe(!wasFav);
// Toggle back
click(favBtn2);
await sleep(100);
});
it('mark done toggles #done on cards without due_date', async () => {
// After clicking, the card has #done — if either filter is on it would
// disappear from the board before the second right-click can find it.
if (state.hideDone || state.hideTagged) return;
// Create a card with no due_date — Mark done used to be hidden in this case
const res = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_mark_done__', body: 'plain text', color: '#38bdf8' }),
}).then(r => r.json());
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
if (!cardEl) return;
rightClick(cardEl);
await sleep(150);
// The action must always be present, even with no due_date
const doneBtn = $$('#ctx-items [data-action="due-done"]')[0];
expect(doneBtn).toExist();
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
click(doneBtn);
await sleep(300);
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
expect((updated[0].body || '').toLowerCase()).toContain('#done');
expect(updated[0].tags || '').toContain('done');
// Re-open menu — the entry should now flip to "Unmark done"
rightClick(document.querySelector(`[data-id="${res.id}"]`));
await sleep(150);
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
click(doneBtn2);
await sleep(300);
updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
expect((updated[0].body || '').toLowerCase().includes('#done')).toBe(false);
});
it('color submenu changes card color', async () => {
const res = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_color_ctx__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
if (!cardEl) return;
rightClick(cardEl);
await sleep(150);
// Click color action to open submenu
click($$('#ctx-items [data-action="color"]')[0]);
await sleep(100);
// Pick a different color
const colorBtn = document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]');
if (colorBtn) {
click(colorBtn);
await sleep(300);
const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
expect(updated.find(c => c.id == res.id).color).toBe('#fb7185');
}
});
});
describe('Card: Bucket Assignment', () => {
afterEach(() => closeAll());
it('card moves to bucket via context menu', async () => {
// Create bucket and card
const bucket = await fetch('api/buckets', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ name: '__test_ctx_bucket__' }),
}).then(r => r.json());
const card = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_ctx_move__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
await window._reloadAll?.();
await sleep(200);
const cardEl = document.querySelector(`[data-id="${card.id}"]`);
if (!cardEl) return;
rightClick(cardEl);
await sleep(150);
// Click bucket action
click($$('#ctx-items [data-action="bucket"]')[0]);
await sleep(100);
// Click the test bucket in submenu
const bucketBtn = $$('#ctx-buckets [data-ctx-bucket]').find(b => b.dataset.ctxBucket == bucket.id);
if (bucketBtn) {
click(bucketBtn);
await sleep(300);
const updated = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
expect(updated.find(c => c.id == card.id).bucket_id).toBe(bucket.id);
}
});
});
describe('Card: Grouping', () => {
afterEach(() => closeAll());
it('cards can be grouped via API', async () => {
const parent = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_parent__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
const child = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_child__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
await fetch(`api/cards/${child.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ parent_id: parent.id }),
});
const all = await fetch(`api/cards?ids=${child.id}`).then(r => r.json());
expect(all.find(c => c.id == child.id).parent_id).toBe(parent.id);
});
it('child card hidden on board (shown under parent)', async () => {
const parent = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_grp_parent__', body: '', color: '#38bdf8' }),
}).then(r => r.json());
const child = await fetch('api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title: '__test_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
}).then(r => r.json());
await window._reloadAll?.();
await sleep(200);
// Child should not be a standalone card on board
const childEl = document.querySelector(`[data-id="${child.id}"]`);
// It may or may not render depending on group view, but parent should exist
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
expect(parentEl).toExist();
});
});