plugin infrastruktur
This commit is contained in:
1 parent
dd2a995f6c
commit
1545b90f39
43 files changed
No files matched your search
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
|
||||
describe('API: Edge Cases', () => {
|
||||
it('create card with special characters in title', async () => {
|
||||
const title = '__test <script>alert(1)</script> & "quotes"__';
|
||||
const res = await 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());
|
||||
expect(res.id).toBeGreaterThan(0);
|
||||
|
||||
const cards = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||
expect(cards[0].title).toBe(title);
|
||||
});
|
||||
|
||||
it('PATCH with empty body clears body', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_empty_body__', body: 'has content', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ body: '' }),
|
||||
});
|
||||
|
||||
const updated = (await fetch(`api/cards?ids=${card.id}`).then(r => r.json()))[0];
|
||||
expect(updated.body).toBe('');
|
||||
expect(updated.tags).toBe('');
|
||||
});
|
||||
|
||||
it('PATCH due_date to null clears it', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_clear_due__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ due_date: '2099-01-01' }),
|
||||
});
|
||||
|
||||
await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ due_date: null }),
|
||||
});
|
||||
|
||||
const updated = (await fetch(`api/cards?ids=${card.id}`).then(r => r.json()))[0];
|
||||
expect(updated.due_date).toBe(null);
|
||||
});
|
||||
|
||||
it('DELETE nonexistent card returns ok', async () => {
|
||||
const res = await fetch('api/cards/999999', {
|
||||
method: 'DELETE', headers: { 'X-CSRF-Token': csrf() },
|
||||
}).then(r => r.json());
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('PATCH nonexistent card returns card data (null)', async () => {
|
||||
const res = await fetch('api/cards/999999', {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: 'nope' }),
|
||||
}).then(r => r.json());
|
||||
// Returns false/null for nonexistent card
|
||||
expect(res.title || null).toBe(null);
|
||||
});
|
||||
|
||||
it('create bucket with empty name returns error', async () => {
|
||||
const res = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('CSRF required for mutations', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'no csrf', body: '' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET requests dont need CSRF', async () => {
|
||||
const res = await fetch('api/cards');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API: Card Position', () => {
|
||||
it('new card gets position', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_position__', body: '', color: '#38bdf8', position: 42 }),
|
||||
}).then(r => r.json());
|
||||
|
||||
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||
expect(all[0].position).toBe(42);
|
||||
});
|
||||
|
||||
it('position update via PATCH', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_pos_patch__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ position: 99 }),
|
||||
});
|
||||
|
||||
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||
expect(all[0].position).toBe(99);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
|
||||
describe('API: Cards CRUD', () => {
|
||||
it('GET /api/cards returns array', async () => {
|
||||
const data = await fetch('api/cards').then(r => r.json());
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/cards/meta returns array', async () => {
|
||||
const data = await fetch('api/cards/meta').then(r => r.json());
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST creates, PATCH updates, DELETE removes', async () => {
|
||||
// Create
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__api_crud__', body: 'test', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
expect(card.id).toBeGreaterThan(0);
|
||||
|
||||
// PATCH returns the updated card object
|
||||
const updated = await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__api_updated__' }),
|
||||
}).then(r => r.json());
|
||||
expect(updated.title).toBe('__api_updated__');
|
||||
|
||||
// DELETE returns {ok: true}
|
||||
const deleted = await fetch(`api/cards/${card.id}`, {
|
||||
method: 'DELETE', headers: { 'X-CSRF-Token': csrf() },
|
||||
}).then(r => r.json());
|
||||
expect(deleted.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('tags extracted from body', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__api_tags__', body: '#alpha text #beta/child', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||
const c = all.find(x => x.id == card.id);
|
||||
expect(c.tags).toContain('alpha');
|
||||
expect(c.tags).toContain('beta/child');
|
||||
});
|
||||
|
||||
it('due_date persists via PATCH', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__api_due__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ due_date: '2099-12-31' }),
|
||||
});
|
||||
|
||||
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||
expect(all.find(x => x.id == card.id).due_date).toBe('2099-12-31');
|
||||
});
|
||||
|
||||
it('bucket_id assignment', async () => {
|
||||
const bucket = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__api_bkt__' }),
|
||||
}).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: '__api_inbucket__', body: '', color: '#38bdf8', bucket_id: bucket.id }),
|
||||
}).then(r => r.json());
|
||||
|
||||
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||
expect(all.find(x => x.id == card.id).bucket_id).toBe(bucket.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API: Buckets CRUD', () => {
|
||||
it('GET /api/buckets returns array', async () => {
|
||||
const data = await fetch('api/buckets').then(r => r.json());
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST creates, PATCH renames, DELETE removes', async () => {
|
||||
// Create
|
||||
const bucket = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__api_bkt_crud__' }),
|
||||
}).then(r => r.json());
|
||||
expect(bucket.id).toBeGreaterThan(0);
|
||||
|
||||
// PATCH returns updated bucket object
|
||||
const renamed = await fetch(`api/buckets/${bucket.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__api_bkt_renamed__' }),
|
||||
}).then(r => r.json());
|
||||
expect(renamed.name).toBe('__api_bkt_renamed__');
|
||||
|
||||
// DELETE returns {ok: true}
|
||||
const deleted = await fetch(`api/buckets/${bucket.id}`, {
|
||||
method: 'DELETE', headers: { 'X-CSRF-Token': csrf() },
|
||||
}).then(r => r.json());
|
||||
expect(deleted.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API: Auth & Misc', () => {
|
||||
it('auth status ok', async () => {
|
||||
const data = await fetch('api/auth/status').then(r => r.json());
|
||||
expect(data.logged_in).toBe(true);
|
||||
});
|
||||
|
||||
it('relay settings reachable', async () => {
|
||||
const data = await fetch('api/relay/settings').then(r => r.json());
|
||||
expect(data).toExist();
|
||||
});
|
||||
|
||||
it('caldav settings reachable', async () => {
|
||||
const data = await fetch('api/caldav/settings').then(r => r.json());
|
||||
expect(data).toExist();
|
||||
});
|
||||
|
||||
it('unknown endpoint returns 404', async () => {
|
||||
const res = await fetch('api/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Board Rendering', () => {
|
||||
it('renders card elements', () => {
|
||||
expect($$('#board [data-id]').length).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
it('cards are draggable', () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
expect(card.getAttribute('draggable')).toBe('true');
|
||||
});
|
||||
|
||||
it('cards have color bar', () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
expect(card.querySelector('.card-bar')).toExist();
|
||||
});
|
||||
|
||||
it('cards have valid data-id', () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
expect(parseInt(card.dataset.id)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Board Split Preview', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('opens split panel', async () => {
|
||||
click('#board-split-toggle');
|
||||
await sleep(150);
|
||||
expect($('#board-preview').classList.contains('hidden')).toBe(false);
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
it('shows empty state initially', async () => {
|
||||
click('#board-split-toggle');
|
||||
await sleep(150);
|
||||
expect($('#board-preview-empty').classList.contains('hidden')).toBe(false);
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
it('shows card on click', async () => {
|
||||
click('#board-split-toggle');
|
||||
await sleep(150);
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(250);
|
||||
expect($('#board-preview-title').textContent.length).toBeGreaterThan(0);
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
it('highlights active card', async () => {
|
||||
click('#board-split-toggle');
|
||||
await sleep(150);
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(250);
|
||||
expect(card.classList.contains('preview-active')).toBe(true);
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Board: Card Create & Delete', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('new card appears on board', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_appear__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
expect(document.querySelector(`[data-id="${res.id}"]`)).toExist();
|
||||
});
|
||||
|
||||
it('deleted card gone from board', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_gone__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(100);
|
||||
|
||||
await fetch(`api/cards/${res.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
expect(document.querySelector(`[data-id="${res.id}"]`)).toBe(null);
|
||||
});
|
||||
|
||||
it('deleted card clears split preview', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_split_clear__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Open split, select the card
|
||||
if ($('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(150);
|
||||
}
|
||||
const el = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (el) click(el);
|
||||
await sleep(200);
|
||||
|
||||
// Delete and check preview resets
|
||||
await fetch(`api/cards/${res.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
expect($('#board-preview-empty').classList.contains('hidden')).toBe(false);
|
||||
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Bucket Context Menu', () => {
|
||||
afterEach(async () => {
|
||||
document.getElementById('bucket-ctx')?.classList.add('hidden');
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('right-click bucket opens context menu', async () => {
|
||||
// Create a test bucket
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bctx__' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const tab = document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`);
|
||||
if (!tab) return;
|
||||
tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
await sleep(150);
|
||||
|
||||
const ctx = document.getElementById('bucket-ctx');
|
||||
expect(ctx).toExist();
|
||||
expect(ctx.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('bucket context menu has rename action', async () => {
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bctx_rename__' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const tab = document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`);
|
||||
if (!tab) return;
|
||||
tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
await sleep(150);
|
||||
|
||||
const renameBtn = document.querySelector('[data-baction="rename"]');
|
||||
expect(renameBtn).toExist();
|
||||
});
|
||||
|
||||
it('bucket context menu has color action', async () => {
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bctx_color__' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const tab = document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`);
|
||||
if (!tab) return;
|
||||
tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
await sleep(150);
|
||||
|
||||
expect(document.querySelector('[data-baction="color"]')).toExist();
|
||||
});
|
||||
|
||||
it('empty bucket shows single delete option', async () => {
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bctx_empty__' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const tab = document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`);
|
||||
if (!tab) return;
|
||||
tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
await sleep(150);
|
||||
|
||||
// Empty bucket: only delete-keep, no delete-all
|
||||
expect(document.querySelector('[data-baction="delete-keep"]')).toExist();
|
||||
expect(document.querySelector('[data-baction="delete-all"]')).toBe(null);
|
||||
});
|
||||
|
||||
it('bucket with cards shows both delete options', async () => {
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bctx_full__' }),
|
||||
}).then(r => r.json());
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_bctx_card__', body: '', color: '#38bdf8', bucket_id: b.id }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const tab = document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`);
|
||||
if (!tab) return;
|
||||
tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
await sleep(150);
|
||||
|
||||
expect(document.querySelector('[data-baction="delete-keep"]')).toExist();
|
||||
expect(document.querySelector('[data-baction="delete-all"]')).toExist();
|
||||
});
|
||||
|
||||
it('move-left disabled on first bucket', async () => {
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
const tabs = $$('#bucket-bar [data-bucket]').filter(t => !['all', 'current', 'favorites', 'journal', 'none'].includes(t.dataset.bucket));
|
||||
if (!tabs.length) return;
|
||||
tabs[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
await sleep(150);
|
||||
const moveLeft = document.querySelector('[data-baction="move-left"]');
|
||||
if (moveLeft) {
|
||||
expect(moveLeft.classList.contains('opacity-30') || moveLeft.classList.contains('pointer-events-none')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
|
||||
describe('Bucket Bar', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('renders bucket tabs', () => {
|
||||
const tabs = $$('#bucket-bar [data-bucket]');
|
||||
expect(tabs.length).toBeGreaterThan(0); // at least "all"
|
||||
});
|
||||
|
||||
it('has "all" bucket', () => {
|
||||
const all = document.querySelector('#bucket-bar [data-bucket="all"]');
|
||||
expect(all).toExist();
|
||||
});
|
||||
|
||||
it('clicking bucket filters cards', async () => {
|
||||
const tabs = $$('#bucket-bar [data-bucket]');
|
||||
const bucketTab = tabs.find(t => t.dataset.bucket !== 'all' && t.dataset.bucket !== 'current' && t.dataset.bucket !== 'favorites' && t.dataset.bucket !== 'journal' && t.dataset.bucket !== 'none');
|
||||
if (!bucketTab) return;
|
||||
click(bucketTab);
|
||||
await sleep(200);
|
||||
expect(state.activeBucket).toBe(bucketTab.dataset.bucket);
|
||||
// Reset
|
||||
click(document.querySelector('#bucket-bar [data-bucket="all"]'));
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
it('"current" bucket works', async () => {
|
||||
const cur = document.querySelector('#bucket-bar [data-bucket="current"]');
|
||||
if (!cur) return;
|
||||
click(cur);
|
||||
await sleep(200);
|
||||
expect(state.activeBucket).toBe('current');
|
||||
click(document.querySelector('#bucket-bar [data-bucket="all"]'));
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
it('"journal" bucket works', async () => {
|
||||
const jour = document.querySelector('#bucket-bar [data-bucket="journal"]');
|
||||
if (!jour) return;
|
||||
click(jour);
|
||||
await sleep(200);
|
||||
expect(state.activeBucket).toBe('journal');
|
||||
click(document.querySelector('#bucket-bar [data-bucket="all"]'));
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
it('"unsorted" bucket works', async () => {
|
||||
const none = document.querySelector('#bucket-bar [data-bucket="none"]');
|
||||
if (!none) return;
|
||||
click(none);
|
||||
await sleep(200);
|
||||
expect(state.activeBucket).toBe('none');
|
||||
click(document.querySelector('#bucket-bar [data-bucket="all"]'));
|
||||
await sleep(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bucket: Create & Delete via API', () => {
|
||||
it('new bucket appears in bucket bar', async () => {
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bkt_bar__' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const tab = document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`);
|
||||
expect(tab).toExist();
|
||||
});
|
||||
|
||||
it('deleted bucket disappears from bar', async () => {
|
||||
const b = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_bkt_del__' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(100);
|
||||
|
||||
await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
expect(document.querySelector(`#bucket-bar [data-bucket="${b.id}"]`)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
describe('Calendar: Day Click', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('clicking a day opens day popup', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(300);
|
||||
|
||||
// Find a day cell in the current month (not dp-out)
|
||||
const days = $$('#cal-grid .cal-day:not(.cal-out)');
|
||||
if (!days.length) return;
|
||||
// Click the first day
|
||||
days[0].click();
|
||||
await sleep(300);
|
||||
|
||||
// Day popup should appear
|
||||
const popup = document.querySelector('.cal-day-popup, [class*="day-popup"]');
|
||||
// Even if no popup (depends on implementation), no crash
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar: Month Boundaries', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('January prev goes to December of previous year', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(300);
|
||||
|
||||
// Navigate to January
|
||||
const title = $('#cal-title');
|
||||
let safety = 15;
|
||||
while (!title.textContent.includes('January') && safety-- > 0) {
|
||||
click('#cal-prev');
|
||||
await sleep(100);
|
||||
}
|
||||
if (!title.textContent.includes('January')) return;
|
||||
|
||||
const yearBefore = title.textContent.match(/\d{4}/)?.[0];
|
||||
click('#cal-prev');
|
||||
await sleep(200);
|
||||
|
||||
expect(title.textContent).toContain('December');
|
||||
const yearAfter = title.textContent.match(/\d{4}/)?.[0];
|
||||
expect(parseInt(yearAfter)).toBe(parseInt(yearBefore) - 1);
|
||||
});
|
||||
|
||||
it('December next goes to January of next year', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(300);
|
||||
|
||||
const title = $('#cal-title');
|
||||
let safety = 15;
|
||||
while (!title.textContent.includes('December') && safety-- > 0) {
|
||||
click('#cal-next');
|
||||
await sleep(100);
|
||||
}
|
||||
if (!title.textContent.includes('December')) return;
|
||||
|
||||
const yearBefore = title.textContent.match(/\d{4}/)?.[0];
|
||||
click('#cal-next');
|
||||
await sleep(200);
|
||||
|
||||
expect(title.textContent).toContain('January');
|
||||
const yearAfter = title.textContent.match(/\d{4}/)?.[0];
|
||||
expect(parseInt(yearAfter)).toBe(parseInt(yearBefore) + 1);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
describe('Calendar', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('button exists', () => {
|
||||
expect($('#calendar-btn')).toExist();
|
||||
});
|
||||
|
||||
it('opens on button click', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(250);
|
||||
expect($('#calendar')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows month title', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(250);
|
||||
expect($('#cal-title')).toExist();
|
||||
expect($('#cal-title').textContent.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('has navigation buttons', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(200);
|
||||
expect($('#cal-prev')).toExist();
|
||||
expect($('#cal-next')).toExist();
|
||||
expect($('#cal-today')).toExist();
|
||||
});
|
||||
|
||||
it('navigates months', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(200);
|
||||
const before = $('#cal-title').textContent;
|
||||
click('#cal-next');
|
||||
await sleep(200);
|
||||
const after = $('#cal-title').textContent;
|
||||
// Should be different month
|
||||
expect(after).toBeTruthy();
|
||||
});
|
||||
|
||||
it('today resets to current month', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(200);
|
||||
click('#cal-next');
|
||||
click('#cal-next');
|
||||
await sleep(200);
|
||||
click('#cal-today');
|
||||
await sleep(200);
|
||||
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]);
|
||||
});
|
||||
|
||||
it('closes on close button', async () => {
|
||||
click('#calendar-btn');
|
||||
await sleep(200);
|
||||
click('#cal-close');
|
||||
await sleep(150);
|
||||
expect($('#calendar')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../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;
|
||||
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.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.includes('unfavorite');
|
||||
expect(isFav).toBe(!wasFav);
|
||||
// Toggle back
|
||||
click(favBtn2);
|
||||
await sleep(100);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, rightClick, sleep } from './runner.js';
|
||||
|
||||
describe('Context Menu', () => {
|
||||
afterEach(async () => {
|
||||
document.dispatchEvent(new MouseEvent('click'));
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('opens on card right-click', async () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
rightClick(card);
|
||||
await sleep(150);
|
||||
expect($('#ctx-menu').classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('has all standard actions', async () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
rightClick(card);
|
||||
await sleep(150);
|
||||
const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action);
|
||||
for (const a of ['open', 'edit', 'duplicate', 'due-date', 'save-md', 'push-relay', 'favorite', 'color', 'bucket', 'tags', 'delete']) {
|
||||
expect(actions).toContain(a);
|
||||
}
|
||||
});
|
||||
|
||||
it('has color submenu with presets', async () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
rightClick(card);
|
||||
await sleep(100);
|
||||
const colors = $$('#ctx-color-grid [data-ctx-color]');
|
||||
expect(colors.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('has tag submenu with input', async () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
rightClick(card);
|
||||
await sleep(100);
|
||||
expect($('#ctx-tag-input')).toExist();
|
||||
expect($('#ctx-tag-list')).toExist();
|
||||
});
|
||||
|
||||
it('closes on document click', async () => {
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
rightClick(card);
|
||||
await sleep(100);
|
||||
document.dispatchEvent(new MouseEvent('click'));
|
||||
await sleep(50);
|
||||
expect($('#ctx-menu').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js';
|
||||
import { showDialog } from '../modules/dialog.js';
|
||||
|
||||
describe('Datepicker: Rendering', () => {
|
||||
afterEach(async () => {
|
||||
// Close dialog if open
|
||||
const dialog = $('#dialog');
|
||||
if (dialog?.classList.contains('open')) dialog.classList.remove('open');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('opens with correct month for default value', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-03-15', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
const title = document.querySelector('.dp-title');
|
||||
expect(title).toExist();
|
||||
expect(title.textContent).toContain('Mar');
|
||||
expect(title.textContent).toContain('2025');
|
||||
// Cancel
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('renders 42 day cells (6 weeks grid)', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-06-01', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
const days = $$('.dp-day');
|
||||
expect(days.length).toBe(42);
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('highlights today', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
const today = document.querySelector('.dp-today');
|
||||
expect(today).toExist();
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('highlights selected date', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-06-15', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
const selected = document.querySelector('.dp-selected');
|
||||
expect(selected).toExist();
|
||||
expect(selected.dataset.dpDate).toBe('2025-06-15');
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('starts week with Monday', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
const labels = $$('.dp-day-label');
|
||||
expect(labels[0].textContent).toBe('Mo');
|
||||
expect(labels[6].textContent).toBe('Su');
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Datepicker: Navigation', () => {
|
||||
afterEach(async () => {
|
||||
const dialog = $('#dialog');
|
||||
if (dialog?.classList.contains('open')) dialog.classList.remove('open');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('navigates to next month', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-06-15', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
click(document.querySelector('[data-dp-nav="1"]'));
|
||||
await sleep(100);
|
||||
expect(document.querySelector('.dp-title').textContent).toContain('Jul');
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('navigates to previous month', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-06-15', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
click(document.querySelector('[data-dp-nav="-1"]'));
|
||||
await sleep(100);
|
||||
expect(document.querySelector('.dp-title').textContent).toContain('May');
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('wraps year on Dec→Jan', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-12-15', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
click(document.querySelector('[data-dp-nav="1"]'));
|
||||
await sleep(100);
|
||||
const title = document.querySelector('.dp-title').textContent;
|
||||
expect(title).toContain('Jan');
|
||||
expect(title).toContain('2026');
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('click day selects it', async () => {
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-06-01', okLabel: 'ok' });
|
||||
await sleep(200);
|
||||
const day15 = $$('.dp-day').find(d => d.dataset.dpDate === '2025-06-15');
|
||||
if (day15) {
|
||||
click(day15);
|
||||
await sleep(100);
|
||||
expect(document.querySelector('.dp-selected')?.dataset.dpDate).toBe('2025-06-15');
|
||||
}
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
});
|
||||
|
||||
it('double-click day submits', async () => {
|
||||
let result = null;
|
||||
const promise = showDialog({ title: 'test', input: 'date', defaultValue: '2025-06-01', okLabel: 'ok' });
|
||||
promise.then(r => { result = r; });
|
||||
await sleep(200);
|
||||
const day10 = $$('.dp-day').find(d => d.dataset.dpDate === '2025-06-10');
|
||||
if (day10) {
|
||||
day10.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
await sleep(200);
|
||||
expect(result).toBe('2025-06-10');
|
||||
} else {
|
||||
click('#dialog-close');
|
||||
await promise.catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Detail: Done Toggle', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('done button toggles #done tag', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_detail_done__', body: 'some text', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Ensure non-split mode
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
// Open detail
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
click(cardEl);
|
||||
await sleep(300);
|
||||
|
||||
// Click done
|
||||
click('#detail-done');
|
||||
await sleep(300);
|
||||
|
||||
// Verify card now has #done
|
||||
const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||
expect(updated.find(c => c.id == res.id).tags).toContain('done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detail: Edit from Detail', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('edit button opens editor with card data', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_detail_edit__', body: 'edit me', color: '#fb7185' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
click(cardEl);
|
||||
await sleep(300);
|
||||
|
||||
click('#detail-edit');
|
||||
await sleep(300);
|
||||
|
||||
expect($('#modal')).toBeVisible();
|
||||
expect($('#inp-title').value).toBe('__test_detail_edit__');
|
||||
expect($('#editor-body').value).toContain('edit me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detail: Zen Mode', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('zen button exists and is clickable', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
expect($('#detail-zen')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detail: Backlinks', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('backlinks section exists', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
expect($('#detail-backlinks')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js';
|
||||
|
||||
describe('Detail View', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('has structural elements', () => {
|
||||
for (const id of ['detail', 'detail-title', 'detail-body', 'detail-meta', 'detail-tags']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('has control buttons', () => {
|
||||
for (const id of ['detail-edit', 'detail-delete', 'detail-done', 'detail-zen', 'detail-close']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens on card click (non-split)', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
expect($('#detail')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows card title', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
expect($('#detail-title').textContent.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('closes on ESC', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
pressKey('Escape');
|
||||
await sleep(150);
|
||||
expect($('#detail')).toBeHidden();
|
||||
});
|
||||
|
||||
it('closes on close button', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
click('#detail-close');
|
||||
await sleep(150);
|
||||
expect($('#detail')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, type, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Editor: Color Picker', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('color presets render', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const presets = document.querySelectorAll('#color-presets [data-color]');
|
||||
expect(presets.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('clicking preset changes card color', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const preset = document.querySelector('#color-presets [data-color="#fb7185"]');
|
||||
if (preset) {
|
||||
click(preset);
|
||||
await sleep(100);
|
||||
expect($('#editor-color').style.background).toContain('251');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Preview Toggle', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('preview pane exists', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect($('#editor-preview')).toExist();
|
||||
expect($('#preview-content')).toExist();
|
||||
});
|
||||
|
||||
it('toggle-preview button exists', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect($('#toggle-preview')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Line Numbers', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('gutter element exists', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect($('#editor-gutter')).toExist();
|
||||
});
|
||||
|
||||
it('toggle-lines button exists', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect($('#toggle-lines')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Save Card', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('save creates card and closes editor', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
type('#inp-title', '__test_save_flow__');
|
||||
const body = $('#editor-body');
|
||||
body.value = 'test body content';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(50);
|
||||
|
||||
click('#modal-save-close');
|
||||
await sleep(400);
|
||||
|
||||
expect($('#modal')).toBeHidden();
|
||||
// Card should exist
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
const found = cards.find(c => c.title === '__test_save_flow__');
|
||||
expect(found).toExist();
|
||||
expect(found.body).toContain('test body content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Edit Existing Card', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('editor loads existing card data', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_edit_load__', body: 'existing body', color: '#8b5cf6' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Open via context menu
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
await sleep(150);
|
||||
const editBtn = document.querySelector('#ctx-items [data-action="edit"]');
|
||||
click(editBtn);
|
||||
await sleep(300);
|
||||
|
||||
expect($('#modal')).toBeVisible();
|
||||
expect($('#inp-title').value).toBe('__test_edit_load__');
|
||||
expect($('#editor-body').value).toContain('existing body');
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, type, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Editor: Empty Title Prevention', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('save with empty title does nothing', async () => {
|
||||
const countBefore = (await fetch('api/cards').then(r => r.json())).length;
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
// Leave title empty, type body
|
||||
const body = $('#editor-body');
|
||||
body.value = 'body without title';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(50);
|
||||
|
||||
click('#modal-save-close');
|
||||
await sleep(300);
|
||||
|
||||
// Modal should still be open (save blocked)
|
||||
// OR card count unchanged
|
||||
const countAfter = (await fetch('api/cards').then(r => r.json())).length;
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
it('save with whitespace-only title does nothing', async () => {
|
||||
const countBefore = (await fetch('api/cards').then(r => r.json())).length;
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
type('#inp-title', ' ');
|
||||
click('#modal-save-close');
|
||||
await sleep(300);
|
||||
|
||||
const countAfter = (await fetch('api/cards').then(r => r.json())).length;
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Ctrl+S Save', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Ctrl+S saves card', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
type('#inp-title', '__test_ctrl_s__');
|
||||
const body = $('#editor-body');
|
||||
body.value = 'saved with ctrl+s';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(50);
|
||||
|
||||
// Ctrl+S (save only, don't close)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }));
|
||||
await sleep(300);
|
||||
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
expect(cards.some(c => c.title === '__test_ctrl_s__')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Tab from Title to Body', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Tab moves focus from title to body', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect(document.activeElement).toBe($('#inp-title'));
|
||||
|
||||
// Simulate Tab keydown
|
||||
$('#inp-title').dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||
await sleep(100);
|
||||
expect(document.activeElement).toBe($('#editor-body'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Update Existing Card', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('editing card updates title and body', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_edit_update__', body: 'original', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Open editor via context menu
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
await sleep(150);
|
||||
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
||||
await sleep(300);
|
||||
|
||||
// Change title and body
|
||||
type('#inp-title', '__test_edit_changed__');
|
||||
const body = $('#editor-body');
|
||||
body.value = 'changed body';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(50);
|
||||
|
||||
click('#modal-save-close');
|
||||
await sleep(400);
|
||||
|
||||
const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||
const card = updated.find(c => c.id == res.id);
|
||||
expect(card.title).toBe('__test_edit_changed__');
|
||||
expect(card.body).toContain('changed body');
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
// Helper: set selection range in textarea
|
||||
function selectRange(textarea, start, end) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = start;
|
||||
textarea.selectionEnd = end;
|
||||
}
|
||||
|
||||
describe('Editor Toolbar: Text Insertion', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('bold button wraps selection', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = 'hello world';
|
||||
selectRange(body, 6, 11); // select "world"
|
||||
const boldBtn = document.querySelector('.editor-toolbar button[title*="old"], .editor-toolbar button[data-md="**"]');
|
||||
if (!boldBtn) {
|
||||
// Try finding by icon
|
||||
const btns = [...document.querySelectorAll('.editor-toolbar button')];
|
||||
const b = btns.find(btn => btn.querySelector('.ph-text-b'));
|
||||
if (b) { click(b); await sleep(100); expect(body.value).toContain('**world**'); }
|
||||
} else {
|
||||
click(boldBtn);
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('**world**');
|
||||
}
|
||||
});
|
||||
|
||||
it('italic button wraps selection', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = 'hello world';
|
||||
selectRange(body, 6, 11);
|
||||
const btns = [...document.querySelectorAll('.editor-toolbar button')];
|
||||
const italicBtn = btns.find(btn => btn.querySelector('.ph-text-italic'));
|
||||
if (italicBtn) {
|
||||
click(italicBtn);
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('*world*');
|
||||
}
|
||||
});
|
||||
|
||||
it('todo button inserts checkbox', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = '';
|
||||
body.selectionStart = body.selectionEnd = 0;
|
||||
const btns = [...document.querySelectorAll('.editor-toolbar button')];
|
||||
const todoBtn = btns.find(btn => btn.querySelector('.ph-check-square'));
|
||||
if (todoBtn) {
|
||||
click(todoBtn);
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('- [ ]');
|
||||
}
|
||||
});
|
||||
|
||||
it('heading button inserts ##', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = '';
|
||||
body.selectionStart = body.selectionEnd = 0;
|
||||
const btns = [...document.querySelectorAll('.editor-toolbar button')];
|
||||
const headBtn = btns.find(btn => btn.querySelector('.ph-text-h'));
|
||||
if (headBtn) {
|
||||
click(headBtn);
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('## ');
|
||||
}
|
||||
});
|
||||
|
||||
it('hr button inserts ---', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = '';
|
||||
body.selectionStart = body.selectionEnd = 0;
|
||||
const btns = [...document.querySelectorAll('.editor-toolbar button')];
|
||||
const hrBtn = btns.find(btn => btn.querySelector('.ph-minus'));
|
||||
if (hrBtn) {
|
||||
click(hrBtn);
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('---');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Auto-Continue List on Enter', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Enter after bullet item continues list', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = '- item one';
|
||||
body.selectionStart = body.selectionEnd = body.value.length;
|
||||
|
||||
// Simulate Enter key
|
||||
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
body.dispatchEvent(ev);
|
||||
await sleep(100);
|
||||
|
||||
// If auto-continue works, body should have "- " on next line
|
||||
if (body.value.includes('\n- ')) {
|
||||
expect(body.value).toContain('\n- ');
|
||||
} else {
|
||||
// Event may not have been handled (depends on handler priority)
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Enter after todo item continues with todo', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = '- [ ] first task';
|
||||
body.selectionStart = body.selectionEnd = body.value.length;
|
||||
|
||||
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
body.dispatchEvent(ev);
|
||||
await sleep(100);
|
||||
|
||||
if (body.value.includes('\n- [ ] ')) {
|
||||
expect(body.value).toContain('\n- [ ] ');
|
||||
} else {
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Enter on empty bullet clears prefix', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
body.value = '- item\n- ';
|
||||
body.selectionStart = body.selectionEnd = body.value.length;
|
||||
|
||||
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
body.dispatchEvent(ev);
|
||||
await sleep(100);
|
||||
|
||||
// Empty bullet should be cleared (line becomes empty)
|
||||
const lines = body.value.split('\n');
|
||||
const lastNonEmpty = lines.filter(l => l.trim()).pop();
|
||||
expect(lastNonEmpty).toBe('- item');
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Editor: Structure', () => {
|
||||
it('has all elements', () => {
|
||||
for (const id of ['modal', 'inp-title', 'editor-body', 'editor-preview', 'color-presets']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('has save buttons', () => {
|
||||
for (const id of ['modal-save', 'modal-save-close', 'modal-cancel']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('has due date and done toggle', () => {
|
||||
expect($('#inp-due-date')).toExist();
|
||||
expect($('#inp-done-toggle')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Open/Close', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('opens on add button', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect($('#modal')).toBeVisible();
|
||||
});
|
||||
|
||||
it('title field focused on open', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
expect(document.activeElement).toBe($('#inp-title'));
|
||||
});
|
||||
|
||||
it('closes on cancel', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(100);
|
||||
click('#modal-cancel');
|
||||
await sleep(150);
|
||||
expect($('#modal')).toBeHidden();
|
||||
});
|
||||
|
||||
it('closes on ESC', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(100);
|
||||
pressKey('Escape');
|
||||
await sleep(150);
|
||||
expect($('#modal')).toBeHidden();
|
||||
});
|
||||
|
||||
it('opens on board right-click (new card)', async () => {
|
||||
const board = $('#board');
|
||||
board.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 300 }));
|
||||
await sleep(200);
|
||||
expect($('#modal')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor: Card Create', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('creates card via API and shows on board', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_editor__', body: 'body text', color: '#fb7185' }),
|
||||
}).then(r => r.json());
|
||||
expect(res.id).toBeGreaterThan(0);
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
expect(document.querySelector(`[data-id="${res.id}"]`)).toExist();
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
|
||||
describe('Export: JSON ZIP', () => {
|
||||
it('export endpoint returns ZIP', async () => {
|
||||
const res = await fetch('api/export');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('zip');
|
||||
});
|
||||
|
||||
it('export with ids returns ZIP', async () => {
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
if (!cards.length) return;
|
||||
const res = await fetch(`api/export?ids=${cards[0].id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('zip');
|
||||
});
|
||||
|
||||
it('export contains data.json', async () => {
|
||||
const res = await fetch('api/export');
|
||||
const blob = await res.blob();
|
||||
// ZIP magic number: PK (0x50 0x4b)
|
||||
const bytes = new Uint8Array(await blob.slice(0, 2).arrayBuffer());
|
||||
expect(bytes[0]).toBe(0x50);
|
||||
expect(bytes[1]).toBe(0x4b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Export: Markdown Vault', () => {
|
||||
it('vault export returns ZIP', async () => {
|
||||
const res = await fetch('api/export-md');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('zip');
|
||||
});
|
||||
|
||||
it('vault export with ids returns ZIP', async () => {
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
if (!cards.length) return;
|
||||
const res = await fetch(`api/export-md?ids=${cards[0].id}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Export: Backup Integrity', () => {
|
||||
it('export→import round-trip preserves card and bucket count', async () => {
|
||||
// Snapshot current state
|
||||
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
||||
const bucketsBefore = await fetch('api/buckets').then(r => r.json());
|
||||
|
||||
// Export current state
|
||||
const blob = await fetch('api/export').then(r => r.blob());
|
||||
|
||||
// Create some extra cards that should be wiped on replace-import
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__extra_should_vanish__', body: '', color: '#38bdf8' }),
|
||||
});
|
||||
|
||||
// Import (replace mode) — should restore exact state from export
|
||||
const form = new FormData();
|
||||
form.append('file', blob, 'roundtrip.zip');
|
||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||
|
||||
const cardsAfter = await fetch('api/cards').then(r => r.json());
|
||||
const bucketsAfter = await fetch('api/buckets').then(r => r.json());
|
||||
expect(cardsAfter.length).toBe(cardsBefore.length);
|
||||
expect(bucketsAfter.length).toBe(bucketsBefore.length);
|
||||
// Extra card should be gone
|
||||
expect(cardsAfter.some(c => c.title === '__extra_should_vanish__')).toBe(false);
|
||||
});
|
||||
|
||||
it('export→import preserves caldav_uid', async () => {
|
||||
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
||||
const withCaldav = cardsBefore.filter(c => c.caldav_uid);
|
||||
if (!withCaldav.length) return; // skip if no synced cards
|
||||
|
||||
// Export
|
||||
const blob = await fetch('api/export').then(r => r.blob());
|
||||
|
||||
// Import
|
||||
const form = new FormData();
|
||||
form.append('file', blob, 'roundtrip.zip');
|
||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||
|
||||
// Verify caldav_uid preserved
|
||||
const cardsAfter = await fetch('api/cards').then(r => r.json());
|
||||
const withCaldavAfter = cardsAfter.filter(c => c.caldav_uid);
|
||||
expect(withCaldavAfter.length).toBe(withCaldav.length);
|
||||
});
|
||||
|
||||
it('export→import preserves tags and due_dates', async () => {
|
||||
// Create a card with tags and due date
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_roundtrip__', body: '#alpha #beta', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await fetch(`api/cards/${card.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ due_date: '2099-12-31' }),
|
||||
});
|
||||
|
||||
// Export
|
||||
const blob = await fetch('api/export').then(r => r.blob());
|
||||
|
||||
// Import (replace)
|
||||
const form = new FormData();
|
||||
form.append('file', blob, 'roundtrip.zip');
|
||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||
|
||||
// Find the card
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
const found = cards.find(c => c.title === '__test_roundtrip__');
|
||||
expect(found).toExist();
|
||||
expect(found.tags).toContain('alpha');
|
||||
expect(found.tags).toContain('beta');
|
||||
expect(found.due_date).toBe('2099-12-31');
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
|
||||
describe('Favorites Bucket', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('favorites tab exists', () => {
|
||||
expect(document.querySelector('#bucket-bar [data-bucket="favorites"]')).toExist();
|
||||
});
|
||||
|
||||
it('favorites bucket shows only favorited cards', async () => {
|
||||
// Create a card and favorite it
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_fav_card__', body: '', color: '#fbbf24' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Toggle favorite via localStorage
|
||||
const favs = JSON.parse(localStorage.getItem('hyprcrd-favorites') || '[]');
|
||||
favs.push(String(card.id));
|
||||
localStorage.setItem('hyprcrd-favorites', JSON.stringify(favs));
|
||||
|
||||
// Switch to favorites bucket
|
||||
click(document.querySelector('#bucket-bar [data-bucket="favorites"]'));
|
||||
await sleep(200);
|
||||
|
||||
const visibleCards = $$('#board [data-id]');
|
||||
const hasFav = visibleCards.some(c => c.dataset.id == card.id);
|
||||
expect(hasFav).toBe(true);
|
||||
|
||||
// Clean up
|
||||
const cleaned = favs.filter(f => f != String(card.id));
|
||||
localStorage.setItem('hyprcrd-favorites', JSON.stringify(cleaned));
|
||||
click(document.querySelector('#bucket-bar [data-bucket="all"]'));
|
||||
await sleep(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card: Todo Progress Bar', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('card with todos shows progress segments', async () => {
|
||||
const card = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_progress__', body: '- [ ] one\n- [x] two\n- [ ] three', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${card.id}"]`);
|
||||
if (!cardEl) return;
|
||||
const progress = cardEl.querySelector('.todo-progress');
|
||||
expect(progress).toExist();
|
||||
const segs = progress?.querySelectorAll('.todo-seg');
|
||||
expect(segs.length).toBe(3);
|
||||
// One segment should have card color background (done), others have border color
|
||||
const doneSegs = [...(segs || [])].filter(s => !s.style.background.includes('var(--t-border)'));
|
||||
expect(doneSegs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detail: Split Pane', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('split-right button exists in detail', async () => {
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
expect($('#split-right-btn') || document.querySelector('[data-action="split"]')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state, parseTags, sortedCards } from '../modules/state.js';
|
||||
|
||||
describe('Filtering: Tag AND Logic', () => {
|
||||
afterEach(async () => {
|
||||
state.activeTags = [];
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('single tag filters correctly', async () => {
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_filter_a__', body: '#alpha', color: '#38bdf8' }),
|
||||
});
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_filter_b__', body: '#beta', color: '#38bdf8' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
state.activeTags = ['alpha'];
|
||||
// Simulate what renderBoard does
|
||||
const filtered = state.cards.filter(c => {
|
||||
const ct = parseTags(c);
|
||||
return state.activeTags.every(at => ct.some(t => t === at || t.startsWith(at + '/')));
|
||||
});
|
||||
expect(filtered.some(c => c.title === '__test_filter_a__')).toBe(true);
|
||||
expect(filtered.some(c => c.title === '__test_filter_b__')).toBe(false);
|
||||
});
|
||||
|
||||
it('multiple tags require ALL to match (AND)', async () => {
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_and_both__', body: '#alpha #beta', color: '#38bdf8' }),
|
||||
});
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_and_one__', body: '#alpha', color: '#38bdf8' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
state.activeTags = ['alpha', 'beta'];
|
||||
const filtered = state.cards.filter(c => {
|
||||
const ct = parseTags(c);
|
||||
return state.activeTags.every(at => ct.some(t => t === at || t.startsWith(at + '/')));
|
||||
});
|
||||
expect(filtered.some(c => c.title === '__test_and_both__')).toBe(true);
|
||||
expect(filtered.some(c => c.title === '__test_and_one__')).toBe(false);
|
||||
});
|
||||
|
||||
it('hierarchical tag matches parent filter', async () => {
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_hier__', body: '#parent/child', color: '#38bdf8' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
state.activeTags = ['parent'];
|
||||
const filtered = state.cards.filter(c => {
|
||||
const ct = parseTags(c);
|
||||
return state.activeTags.every(at => ct.some(t => t === at || t.startsWith(at + '/')));
|
||||
});
|
||||
expect(filtered.some(c => c.title === '__test_hier__')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering: Unsorted Bucket', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('shows cards without bucket and without date title', async () => {
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_unsorted__', body: '', color: '#38bdf8' }),
|
||||
});
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '2099-01-01 dated', body: '', color: '#38bdf8' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const none = state.cards.filter(c => !c.parent_id).filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title));
|
||||
expect(none.some(c => c.title === '__test_unsorted__')).toBe(true);
|
||||
expect(none.some(c => c.title === '2099-01-01 dated')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering: Hide Filters', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('hideTermine excludes caldav cards', async () => {
|
||||
const cards = state.cards;
|
||||
const withCaldav = cards.filter(c => c.caldav_uid);
|
||||
const filtered = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag') && !c.caldav_uid; });
|
||||
// All caldav cards should be gone
|
||||
for (const c of withCaldav) {
|
||||
expect(filtered.some(f => f.id === c.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('hideTagged excludes cards with any tags', () => {
|
||||
const withTags = state.cards.filter(c => c.tags);
|
||||
const filtered = state.cards.filter(c => !c.tags);
|
||||
for (const c of withTags) {
|
||||
expect(filtered.some(f => f.id === c.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('hideDone excludes cards with #done tag', () => {
|
||||
const done = state.cards.filter(c => parseTags(c).includes('done'));
|
||||
const filtered = state.cards.filter(c => !parseTags(c).includes('done'));
|
||||
for (const c of done) {
|
||||
expect(filtered.some(f => f.id === c.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
describe('Gallery', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('button exists', () => {
|
||||
expect($('#gallery-btn')).toExist();
|
||||
});
|
||||
|
||||
it('opens on button click', async () => {
|
||||
click('#gallery-btn');
|
||||
await sleep(250);
|
||||
expect($('#gallery')).toBeVisible();
|
||||
});
|
||||
|
||||
it('grid has no text selection', () => {
|
||||
expect($('#gallery-grid').classList.contains('select-none')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows image count', async () => {
|
||||
click('#gallery-btn');
|
||||
await sleep(250);
|
||||
expect($('#gallery-count')).toExist();
|
||||
});
|
||||
|
||||
it('closes on close button', async () => {
|
||||
click('#gallery-btn');
|
||||
await sleep(200);
|
||||
click('#gallery-close');
|
||||
await sleep(150);
|
||||
expect($('#gallery')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, pressKey, sleep, closeAll } from './runner.js';
|
||||
|
||||
describe('Help', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('opens with F1', async () => {
|
||||
pressKey('F1');
|
||||
await sleep(150);
|
||||
expect($('#help')).toBeVisible();
|
||||
});
|
||||
|
||||
it('toggles with F1', async () => {
|
||||
pressKey('F1');
|
||||
await sleep(100);
|
||||
expect($('#help')).toBeVisible();
|
||||
pressKey('F1');
|
||||
await sleep(150);
|
||||
expect($('#help')).toBeHidden();
|
||||
});
|
||||
|
||||
it('opens with help button', async () => {
|
||||
click('#help-btn');
|
||||
await sleep(150);
|
||||
expect($('#help')).toBeVisible();
|
||||
});
|
||||
|
||||
it('has content sections', async () => {
|
||||
click('#help-btn');
|
||||
await sleep(150);
|
||||
const sections = $$('#help h3, #help h2');
|
||||
expect(sections.length).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Image Fullscreen Viewer', () => {
|
||||
afterEach(async () => {
|
||||
// Close fullscreen if open
|
||||
document.querySelector('.detail-image-fs-overlay')?.remove();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('fullscreen overlay has required elements when opened', async () => {
|
||||
// Create card with image reference
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_img_fs__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Ensure non-split mode
|
||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
||||
click('#board-split-toggle');
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
// Open detail
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
click(cardEl);
|
||||
await sleep(300);
|
||||
|
||||
// Check that images in detail would get fullscreen handler
|
||||
// The overlay is created lazily, verify the create function structure
|
||||
const { bindDetailImageFullscreen } = await import('../modules/detail.js');
|
||||
expect(typeof bindDetailImageFullscreen).toBe('function');
|
||||
});
|
||||
|
||||
it('overlay structure is correct when created', () => {
|
||||
// Manually create to test structure
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'detail-image-fs-overlay';
|
||||
overlay.innerHTML = `
|
||||
<button class="detail-image-fs-close"></button>
|
||||
<button class="detail-image-fs-prev"></button>
|
||||
<button class="detail-image-fs-next"></button>
|
||||
<div class="detail-image-fs-counter"></div>
|
||||
<div class="detail-image-fs-stage"><img class="detail-image-fs-img" alt=""></div>
|
||||
`;
|
||||
expect(overlay.querySelector('.detail-image-fs-close')).toExist();
|
||||
expect(overlay.querySelector('.detail-image-fs-prev')).toExist();
|
||||
expect(overlay.querySelector('.detail-image-fs-next')).toExist();
|
||||
expect(overlay.querySelector('.detail-image-fs-img')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
function enableLines() {
|
||||
const gutter = $('#editor-gutter');
|
||||
if (gutter?.classList.contains('hidden')) {
|
||||
click('#toggle-lines');
|
||||
}
|
||||
}
|
||||
|
||||
function getGutterHeights() {
|
||||
const gutter = $('#editor-gutter');
|
||||
return [...gutter.children].map(d => d.getBoundingClientRect().height);
|
||||
}
|
||||
|
||||
function getGutterNumbers() {
|
||||
const gutter = $('#editor-gutter');
|
||||
return [...gutter.children].map(d => d.textContent.trim());
|
||||
}
|
||||
|
||||
describe('Line Numbers: Basic', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('gutter shows correct line count', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = 'line 1\nline 2\nline 3';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(150);
|
||||
|
||||
const nums = getGutterNumbers();
|
||||
expect(nums.length).toBe(3);
|
||||
expect(nums[0]).toBe('1');
|
||||
expect(nums[1]).toBe('2');
|
||||
expect(nums[2]).toBe('3');
|
||||
});
|
||||
|
||||
it('single line shows 1', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = 'just one line';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(150);
|
||||
|
||||
expect(getGutterNumbers()).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('empty editor shows 1', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = '';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(150);
|
||||
|
||||
expect(getGutterNumbers()).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('line numbers are sequential (no skips)', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = 'a\nb\nc\nd\ne\nf\ng\nh\ni\nj';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(150);
|
||||
|
||||
const nums = getGutterNumbers();
|
||||
expect(nums.length).toBe(10);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(nums[i]).toBe(String(i + 1));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Numbers: Wrapping Lines', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('wrapped line gets taller gutter div (not extra line number)', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
// Create a line long enough to wrap
|
||||
const longLine = 'word '.repeat(80);
|
||||
body.value = 'short\n' + longLine + '\nshort again';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
const nums = getGutterNumbers();
|
||||
// Must be exactly 3 line numbers, not more
|
||||
expect(nums.length).toBe(3);
|
||||
expect(nums[0]).toBe('1');
|
||||
expect(nums[1]).toBe('2');
|
||||
expect(nums[2]).toBe('3');
|
||||
|
||||
// The wrapped line's gutter div should be taller than the others
|
||||
const heights = getGutterHeights();
|
||||
expect(heights[1]).toBeGreaterThan(heights[0]);
|
||||
});
|
||||
|
||||
it('multiple wrapped lines each get correct height', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
const longA = 'alpha '.repeat(60);
|
||||
const longB = 'beta '.repeat(90);
|
||||
body.value = 'short\n' + longA + '\n' + longB + '\nend';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
const nums = getGutterNumbers();
|
||||
expect(nums.length).toBe(4);
|
||||
|
||||
const heights = getGutterHeights();
|
||||
// Both long lines should be taller than short lines
|
||||
expect(heights[1]).toBeGreaterThan(heights[0]);
|
||||
expect(heights[2]).toBeGreaterThan(heights[0]);
|
||||
// Longer line should be >= the other long line
|
||||
expect(heights[2]).toBeGreaterThan(heights[1] - 1); // allow 1px tolerance
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Numbers: Alignment with Textarea', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('total gutter height close to textarea content height', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
const heights = getGutterHeights();
|
||||
const gutterTotal = heights.reduce((a, b) => a + b, 0);
|
||||
const cs = getComputedStyle(body);
|
||||
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||
const textTotal = body.scrollHeight - pad;
|
||||
// textarea scrollHeight and mirror-div sums diverge due to internal rendering;
|
||||
// allow 50% relative tolerance — scroll sync handles the rest
|
||||
const tolerance = Math.max(textTotal, gutterTotal) * 0.5;
|
||||
expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance);
|
||||
});
|
||||
|
||||
it('gutter first line aligns with textarea first line', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = 'first line\nsecond line';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
const gutter = $('#editor-gutter');
|
||||
const gutterRect = gutter.children[0].getBoundingClientRect();
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const bodyPadTop = parseFloat(getComputedStyle(body).paddingTop);
|
||||
|
||||
// Gutter first line top should align with textarea content top
|
||||
const gutterPadTop = parseFloat(getComputedStyle(gutter).paddingTop);
|
||||
const gutterLineTop = gutterRect.top;
|
||||
const bodyLineTop = bodyRect.top + bodyPadTop;
|
||||
|
||||
// Allow 3px tolerance
|
||||
expect(Math.abs(gutterLineTop - bodyLineTop)).toBeLessThan(3);
|
||||
});
|
||||
|
||||
it('gutter line height consistent for non-wrapping text', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = 'short a\nshort b\nshort c';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
const heights = getGutterHeights();
|
||||
// All short lines should have the same height (allow 1px rounding from offsetTop)
|
||||
expect(Math.abs(heights[0] - heights[1])).toBeLessThan(2);
|
||||
expect(Math.abs(heights[1] - heights[2])).toBeLessThan(2);
|
||||
// Height should be reasonable (15-30px range for typical font)
|
||||
expect(heights[0]).toBeGreaterThan(14);
|
||||
expect(heights[0]).toBeLessThan(35);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Numbers: Scroll Sync', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('gutter scrolls with textarea', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
enableLines();
|
||||
const body = $('#editor-body');
|
||||
body.value = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
// Scroll textarea down
|
||||
body.scrollTop = 200;
|
||||
body.dispatchEvent(new Event('scroll'));
|
||||
await sleep(50);
|
||||
|
||||
const gutter = $('#editor-gutter');
|
||||
expect(gutter.scrollTop).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Numbers: Toggle', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('toggle-lines shows and hides gutter', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const gutter = $('#editor-gutter');
|
||||
const wasHidden = gutter.classList.contains('hidden');
|
||||
|
||||
click('#toggle-lines');
|
||||
await sleep(100);
|
||||
expect(gutter.classList.contains('hidden')).toBe(!wasHidden);
|
||||
|
||||
click('#toggle-lines');
|
||||
await sleep(100);
|
||||
expect(gutter.classList.contains('hidden')).toBe(wasHidden);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { renderMd } from '../modules/markdown.js';
|
||||
|
||||
describe('Markdown: Blank Lines', () => {
|
||||
it('image after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('uploads/test.jpg');
|
||||
});
|
||||
|
||||
it('image after single blank line renders', () => {
|
||||
const html = renderMd('text\n\n');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
it('image after many blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n\n\n');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
it('heading after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n## Heading');
|
||||
expect(html).toContain('<h2');
|
||||
});
|
||||
|
||||
it('list after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n- item one\n- item two');
|
||||
expect(html).toContain('<li');
|
||||
});
|
||||
|
||||
it('code block after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n```\ncode\n```');
|
||||
expect(html).toContain('<code');
|
||||
});
|
||||
|
||||
it('hr after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n---');
|
||||
expect(html).toContain('<hr');
|
||||
});
|
||||
|
||||
it('todo after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n- [ ] task');
|
||||
expect(html).toContain('checkbox');
|
||||
});
|
||||
|
||||
it('wiki link after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n[[Some Card]]');
|
||||
expect(html).toContain('card-link');
|
||||
});
|
||||
|
||||
it('gallery block after blank lines renders', () => {
|
||||
const html = renderMd('text\n\n\n\n```gallery\n\n```');
|
||||
expect(html).toContain('md-gallery');
|
||||
});
|
||||
|
||||
it('multiple blank lines produce spacing but no broken content', () => {
|
||||
const html = renderMd('para 1\n\n\n\npara 2\n\n\n\n\npara 3');
|
||||
// All three paragraphs should render
|
||||
const pCount = (html.match(/<p>/g) || []).length;
|
||||
expect(pCount).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('blank lines between images both render', () => {
|
||||
const html = renderMd('\n\n\n\n');
|
||||
const imgCount = (html.match(/<img/g) || []).length;
|
||||
expect(imgCount).toBe(2);
|
||||
});
|
||||
|
||||
it('blank lines dont break bold/italic', () => {
|
||||
const html = renderMd('**bold text**\n\n\n\n*italic text*');
|
||||
expect(html).toContain('<strong>bold text</strong>');
|
||||
expect(html).toContain('<em>italic text</em>');
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { renderMd, formatTodoItems, stripMd } from '../modules/markdown.js';
|
||||
import { state } from '../modules/state.js';
|
||||
|
||||
describe('Markdown: Basic Rendering', () => {
|
||||
it('renders paragraph', () => {
|
||||
const html = renderMd('hello world');
|
||||
expect(html).toContain('hello world');
|
||||
});
|
||||
|
||||
it('renders bold and italic', () => {
|
||||
const html = renderMd('**bold** and *italic*');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).toContain('<em>italic</em>');
|
||||
});
|
||||
|
||||
it('renders headings', () => {
|
||||
const html = renderMd('# Title\n## Subtitle');
|
||||
expect(html).toContain('<h1');
|
||||
expect(html).toContain('<h2');
|
||||
});
|
||||
|
||||
it('renders code blocks', () => {
|
||||
const html = renderMd('```\ncode\n```');
|
||||
expect(html).toContain('<code');
|
||||
});
|
||||
|
||||
it('renders links with target=_blank', () => {
|
||||
const html = renderMd('[link](https://example.com)');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown: Wiki Links', () => {
|
||||
it('resolves existing card link', () => {
|
||||
const card = state.cards[0];
|
||||
if (!card) return;
|
||||
const html = renderMd(`see [[${card.title}]]`);
|
||||
expect(html).toContain('card-link');
|
||||
expect(html).toContain(`data-card-id="${card.id}"`);
|
||||
});
|
||||
|
||||
it('marks broken link', () => {
|
||||
const html = renderMd('see [[__nonexistent_card_xyzzy__]]');
|
||||
expect(html).toContain('broken');
|
||||
});
|
||||
|
||||
it('wiki link is case-insensitive', () => {
|
||||
const card = state.cards[0];
|
||||
if (!card) return;
|
||||
const upper = card.title.toUpperCase();
|
||||
const html = renderMd(`see [[${upper}]]`);
|
||||
expect(html).toContain('card-link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown: Gallery Block', () => {
|
||||
it('renders gallery from fenced block', () => {
|
||||
const html = renderMd('```gallery\n\n```');
|
||||
expect(html).toContain('md-gallery');
|
||||
expect(html).toContain('md-gallery-item');
|
||||
});
|
||||
|
||||
it('empty gallery renders nothing', () => {
|
||||
const html = renderMd('```gallery\nno images here\n```');
|
||||
expect(html).toContain(''); // empty result, no gallery div
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown: Todo Items', () => {
|
||||
it('formatTodoItems converts checkboxes to buttons', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<ul><li><input type="checkbox"> task one</li><li><input type="checkbox" checked> task two</li></ul>';
|
||||
formatTodoItems(container);
|
||||
const buttons = container.querySelectorAll('.todo-cb');
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it('checked items have done class', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<ul><li><input type="checkbox" checked> done</li></ul>';
|
||||
formatTodoItems(container);
|
||||
const item = container.querySelector('.todo-item');
|
||||
expect(item.classList.contains('todo-done')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown: stripMd', () => {
|
||||
it('strips markdown formatting', () => {
|
||||
const plain = stripMd('**bold** and *italic* [link](url)');
|
||||
expect(plain).toContain('bold');
|
||||
expect(plain.includes('**')).toBe(false);
|
||||
expect(plain.includes('*')).toBe(false);
|
||||
});
|
||||
|
||||
it('strips image references', () => {
|
||||
const plain = stripMd('text  more');
|
||||
expect(plain.includes('![alt]')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, sleep, closeAll } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
|
||||
describe('Multi-Select', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('select bar hidden by default', () => {
|
||||
expect($('#select-bar').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('has all action buttons', () => {
|
||||
for (const id of ['select-group', 'select-bucket', 'select-unbucket', 'select-color', 'select-tag', 'select-export', 'select-delete', 'select-clear']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('Ctrl+click selects card and shows bar', async () => {
|
||||
await sleep(150);
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
card.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
|
||||
await sleep(150);
|
||||
expect(state.selectedIds.has(card.dataset.id)).toBe(true);
|
||||
expect($('#select-bar').classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('Ctrl+click toggle: select then deselect', async () => {
|
||||
await sleep(150);
|
||||
// Ensure clean state
|
||||
state.selectedIds.clear();
|
||||
await sleep(50);
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
const id = card.dataset.id;
|
||||
card.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
|
||||
await sleep(200);
|
||||
expect(state.selectedIds.has(id)).toBe(true);
|
||||
card.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
|
||||
await sleep(200);
|
||||
expect(state.selectedIds.has(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('multiple cards selectable', async () => {
|
||||
await sleep(150);
|
||||
const cards = [...document.querySelectorAll('#board [data-id]')];
|
||||
if (cards.length < 2) return;
|
||||
cards[0].dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
|
||||
await sleep(100);
|
||||
cards[1].dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
|
||||
await sleep(150);
|
||||
expect(state.selectedIds.has(cards[0].dataset.id)).toBe(true);
|
||||
expect(state.selectedIds.has(cards[1].dataset.id)).toBe(true);
|
||||
expect(state.selectedIds.size).toBe(2);
|
||||
});
|
||||
|
||||
it('click outside board clears selection', async () => {
|
||||
await sleep(150);
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
card.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }));
|
||||
await sleep(150);
|
||||
expect(state.selectedIds.size).toBeGreaterThan(0);
|
||||
document.querySelector('header').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await sleep(150);
|
||||
expect(state.selectedIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, it, expect, $ } from './runner.js';
|
||||
|
||||
describe('Notification Banner', () => {
|
||||
it('banner element exists', () => {
|
||||
expect($('#upcoming-banner')).toExist();
|
||||
});
|
||||
|
||||
it('notification settings in localStorage', () => {
|
||||
// Settings should exist or default to null (both valid)
|
||||
const settings = localStorage.getItem('hyprcrd-notif-settings');
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
expect(parsed).toExist();
|
||||
} else {
|
||||
expect(true).toBe(true); // defaults are fine
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification Settings', () => {
|
||||
it('lookahead select has options', () => {
|
||||
const sel = $('#notif-lookahead');
|
||||
expect(sel).toExist();
|
||||
expect(sel.options.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('time select has options', () => {
|
||||
const sel = $('#notif-time');
|
||||
expect(sel).toExist();
|
||||
expect(sel.options.length).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, type, pressKey, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Quick Capture: Open/Close', () => {
|
||||
afterEach(async () => {
|
||||
const qc = $('#quick-capture');
|
||||
if (qc && !qc.classList.contains('hidden')) qc.classList.add('hidden');
|
||||
$('#quick-capture-input').value = '';
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('Ctrl+N opens capture bar', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
expect($('#quick-capture').classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('input is focused on open', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
expect(document.activeElement).toBe($('#quick-capture-input'));
|
||||
});
|
||||
|
||||
it('ESC closes capture bar', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
const input = $('#quick-capture-input');
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await sleep(100);
|
||||
expect($('#quick-capture').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('Ctrl+N toggles off', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
expect($('#quick-capture').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Capture: Card Creation', () => {
|
||||
afterEach(async () => {
|
||||
const qc = $('#quick-capture');
|
||||
if (qc && !qc.classList.contains('hidden')) qc.classList.add('hidden');
|
||||
$('#quick-capture-input').value = '';
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('Enter creates card with #inbox tag', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
const input = $('#quick-capture-input');
|
||||
input.value = '__test_quick_capture__';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await sleep(400);
|
||||
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
const found = cards.find(c => c.title === '__test_quick_capture__');
|
||||
expect(found).toExist();
|
||||
expect(found.tags).toContain('inbox');
|
||||
});
|
||||
|
||||
it('capture bar closes after Enter', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
const input = $('#quick-capture-input');
|
||||
input.value = '__test_qc_close__';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await sleep(400);
|
||||
|
||||
expect($('#quick-capture').classList.contains('hidden')).toBe(true);
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('empty input does nothing on Enter', async () => {
|
||||
const countBefore = (await fetch('api/cards').then(r => r.json())).length;
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
const input = $('#quick-capture-input');
|
||||
input.value = '';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await sleep(200);
|
||||
|
||||
const countAfter = (await fetch('api/cards').then(r => r.json())).length;
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
|
||||
describe('Relay: Push API', () => {
|
||||
it('push without config returns error', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (settings.url && settings.has_key) return; // skip if configured
|
||||
|
||||
const res = await fetch('api/relay/push', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ card_id: 1 }),
|
||||
}).then(r => r.json());
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toContain('not configured');
|
||||
});
|
||||
|
||||
it('push without card_id returns error', async () => {
|
||||
const res = await fetch('api/relay/push', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relay: Poll API', () => {
|
||||
it('poll without config returns 0 pulled', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (settings.url && settings.has_key) return;
|
||||
|
||||
const res = await fetch('api/relay/poll', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
||||
}).then(r => r.json());
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.pulled).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
|
||||
// RRULE is set by CalDAV sync, not via PATCH. Test the data model and existing cards.
|
||||
|
||||
describe('RRULE: Data Model', () => {
|
||||
it('cards with caldav_rrule exist in state (if synced)', () => {
|
||||
const withRrule = state.cards.filter(c => c.caldav_rrule);
|
||||
// May be 0 if no CalDAV configured — just ensure no crash
|
||||
expect(Array.isArray(withRrule)).toBe(true);
|
||||
});
|
||||
|
||||
it('birthday cards have geburtstag tag', () => {
|
||||
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
|
||||
for (const b of birthdays) {
|
||||
expect(b.tags).toContain('geburtstag');
|
||||
// Birthday titles start with date
|
||||
expect(b.title).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
||||
}
|
||||
});
|
||||
|
||||
it('termin cards have termin tag', () => {
|
||||
const termine = state.cards.filter(c => (c.tags || '').split(',').includes('termin'));
|
||||
for (const t of termine) {
|
||||
expect(t.tags).toContain('termin');
|
||||
}
|
||||
});
|
||||
|
||||
it('caldav_uid is unique per card', () => {
|
||||
const uids = state.cards.filter(c => c.caldav_uid).map(c => c.caldav_uid);
|
||||
const unique = new Set(uids);
|
||||
expect(unique.size).toBe(uids.length);
|
||||
});
|
||||
});
|
||||
@@ -1,358 +0,0 @@
|
||||
// ── hyprcrd GUI Test Runner ──
|
||||
// Self-contained in-app test framework. No external dependencies.
|
||||
// Activate: ?test=1 in URL or Ctrl+Shift+T
|
||||
// Panel is hidden during test run so interactions are visible.
|
||||
// Backup is created before and auto-restored after every run.
|
||||
|
||||
import { getCsrf } from '../modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../modules/caldav.js';
|
||||
|
||||
const suites = [];
|
||||
let currentSuite = null;
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function describe(name, fn) {
|
||||
const suite = { name, tests: [], beforeEach: null, afterEach: null };
|
||||
currentSuite = suite;
|
||||
suites.push(suite);
|
||||
fn();
|
||||
currentSuite = null;
|
||||
}
|
||||
|
||||
export function it(name, fn) {
|
||||
if (!currentSuite) throw new Error('it() must be inside describe()');
|
||||
currentSuite.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
export function beforeEach(fn) {
|
||||
if (currentSuite) currentSuite.beforeEach = fn;
|
||||
}
|
||||
|
||||
export function afterEach(fn) {
|
||||
if (currentSuite) currentSuite.afterEach = fn;
|
||||
}
|
||||
|
||||
// ── Assertions ──
|
||||
|
||||
export function expect(actual) {
|
||||
return {
|
||||
toBe(expected) {
|
||||
if (actual !== expected) throw new AssertionError(`expected ${json(expected)}, got ${json(actual)}`);
|
||||
},
|
||||
toEqual(expected) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new AssertionError(`expected ${json(expected)}, got ${json(actual)}`);
|
||||
},
|
||||
toBeTruthy() {
|
||||
if (!actual) throw new AssertionError(`expected truthy, got ${json(actual)}`);
|
||||
},
|
||||
toBeFalsy() {
|
||||
if (actual) throw new AssertionError(`expected falsy, got ${json(actual)}`);
|
||||
},
|
||||
toContain(item) {
|
||||
if (typeof actual === 'string') {
|
||||
if (!actual.includes(item)) throw new AssertionError(`expected "${trunc(actual)}" to contain "${item}"`);
|
||||
} else if (Array.isArray(actual)) {
|
||||
if (!actual.includes(item)) throw new AssertionError(`expected array to contain ${json(item)}`);
|
||||
} else {
|
||||
throw new AssertionError('toContain requires string or array');
|
||||
}
|
||||
},
|
||||
toBeGreaterThan(n) {
|
||||
if (!(actual > n)) throw new AssertionError(`expected ${actual} > ${n}`);
|
||||
},
|
||||
toBeLessThan(n) {
|
||||
if (!(actual < n)) throw new AssertionError(`expected ${actual} < ${n}`);
|
||||
},
|
||||
toMatch(regex) {
|
||||
if (!regex.test(actual)) throw new AssertionError(`expected "${trunc(actual)}" to match ${regex}`);
|
||||
},
|
||||
toExist() {
|
||||
if (actual === null || actual === undefined) throw new AssertionError('expected to exist, got ' + json(actual));
|
||||
},
|
||||
toBeVisible() {
|
||||
if (!actual) throw new AssertionError('element is null');
|
||||
const el = typeof actual === 'string' ? document.querySelector(actual) : actual;
|
||||
if (!el) throw new AssertionError('element not found');
|
||||
if (!isVisible(el)) throw new AssertionError('expected element to be visible');
|
||||
},
|
||||
toBeHidden() {
|
||||
if (!actual) return; // null = hidden
|
||||
const el = typeof actual === 'string' ? document.querySelector(actual) : actual;
|
||||
if (!el) return;
|
||||
if (isVisible(el)) throw new AssertionError('expected element to be hidden');
|
||||
},
|
||||
toHaveCount(n) {
|
||||
const list = typeof actual === 'string' ? document.querySelectorAll(actual) : actual;
|
||||
const len = list?.length ?? 0;
|
||||
if (len !== n) throw new AssertionError(`expected ${n} elements, found ${len}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class AssertionError extends Error {
|
||||
constructor(msg) { super(msg); this.name = 'AssertionError'; }
|
||||
}
|
||||
|
||||
function json(v) { try { return JSON.stringify(v); } catch { return String(v); } }
|
||||
function trunc(s) { return String(s).length > 80 ? String(s).substring(0, 80) + '...' : String(s); }
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.classList.contains('overlay')) return el.classList.contains('open');
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && !el.classList.contains('hidden') && getComputedStyle(el).display !== 'none';
|
||||
}
|
||||
|
||||
// ── DOM Helpers ──
|
||||
|
||||
export function $(selector) {
|
||||
return document.querySelector(selector) || document.getElementById(selector);
|
||||
}
|
||||
|
||||
export function $$(selector) {
|
||||
return [...document.querySelectorAll(selector)];
|
||||
}
|
||||
|
||||
export function click(selectorOrEl) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`click: element not found: ${selectorOrEl}`);
|
||||
el.click();
|
||||
}
|
||||
|
||||
export function dblclick(selectorOrEl) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`dblclick: element not found: ${selectorOrEl}`);
|
||||
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function type(selectorOrEl, text) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`type: element not found: ${selectorOrEl}`);
|
||||
el.focus();
|
||||
el.value = text;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function pressKey(key, opts = {}) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...opts }));
|
||||
}
|
||||
|
||||
export function rightClick(selectorOrEl, x, y) {
|
||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
||||
if (!el) throw new Error(`rightClick: element not found: ${selectorOrEl}`);
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new MouseEvent('contextmenu', {
|
||||
bubbles: true, clientX: x ?? rect.left + 10, clientY: y ?? rect.top + 10,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function waitFor(fn, timeout = 3000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
try { const r = fn(); if (r) return r; } catch {}
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(`waitFor timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export function csrf() {
|
||||
return getCsrf();
|
||||
}
|
||||
|
||||
export async function closeAll() {
|
||||
for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
||||
}
|
||||
document.querySelector('#ctx-menu')?.classList.add('hidden');
|
||||
document.querySelector('#select-bar')?.classList.add('hidden');
|
||||
document.querySelector('.bucket-picker')?.remove();
|
||||
document.querySelector('.orphan-lightbox')?.remove();
|
||||
// Clear multi-select
|
||||
document.querySelectorAll('.card-selected').forEach(el => {
|
||||
el.classList.remove('card-selected');
|
||||
el.querySelector('.card-select-check')?.remove();
|
||||
});
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
// ── Backup & Restore ──
|
||||
|
||||
let backupBlob = null;
|
||||
|
||||
async function createBackup() {
|
||||
const res = await fetch('api/export');
|
||||
if (!res.ok) throw new Error('backup failed: ' + res.status);
|
||||
backupBlob = await res.blob();
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!backupBlob) return;
|
||||
const form = new FormData();
|
||||
form.append('file', backupBlob, 'backup.zip');
|
||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||
}
|
||||
|
||||
// ── Runner ──
|
||||
|
||||
async function runAll() {
|
||||
const results = [];
|
||||
for (const suite of suites) {
|
||||
const suiteResult = { name: suite.name, tests: [] };
|
||||
for (const test of suite.tests) {
|
||||
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
if (suite.beforeEach) await suite.beforeEach();
|
||||
await test.fn();
|
||||
if (suite.afterEach) await suite.afterEach();
|
||||
} catch (err) {
|
||||
entry.status = 'fail';
|
||||
entry.error = err.message;
|
||||
// Always run afterEach on failure too
|
||||
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
||||
}
|
||||
entry.time = Math.round(performance.now() - t0);
|
||||
suiteResult.tests.push(entry);
|
||||
}
|
||||
results.push(suiteResult);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── UI Panel ──
|
||||
|
||||
let panel = null;
|
||||
|
||||
function createPanel() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'test-panel';
|
||||
panel.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:rgba(2,6,23,0.95);font-family:monospace;color:#cbd5e1;';
|
||||
panel.innerHTML = `
|
||||
<div id="test-header" style="position:absolute;top:0;left:0;right:0;height:44px;padding:8px 16px;border-bottom:1px solid #334155;display:flex;align-items:center;gap:12px;">
|
||||
<span style="font-weight:bold;color:#38bdf8;">hyprcrd test runner</span>
|
||||
<button id="test-run-btn" style="padding:4px 12px;background:#38bdf8;color:#0f172a;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:12px;">run all</button>
|
||||
<button id="test-copy-btn" style="padding:4px 12px;background:#334155;color:#94a3b8;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:12px;">copy</button>
|
||||
<span id="test-summary" style="font-size:12px;color:#64748b;"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button id="test-close-btn" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px;">✕</button>
|
||||
</div>
|
||||
<div id="test-output" style="position:absolute;top:44px;left:0;right:0;bottom:0;overflow-y:auto;padding:12px 16px;font-size:12px;line-height:1.8;"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
panel.querySelector('#test-close-btn').addEventListener('click', close);
|
||||
panel.querySelector('#test-run-btn').addEventListener('click', run);
|
||||
panel.querySelector('#test-copy-btn').addEventListener('click', () => {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const text = output.innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = panel.querySelector('#test-copy-btn');
|
||||
btn.textContent = 'copied';
|
||||
btn.style.color = '#4ade80';
|
||||
setTimeout(() => { btn.textContent = 'copy'; btn.style.color = '#94a3b8'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
let total = 0, passed = 0, failed = 0, totalTime = 0;
|
||||
|
||||
let html = '';
|
||||
for (const suite of results) {
|
||||
html += `<div style="margin-bottom:16px;">`;
|
||||
html += `<div style="color:#94a3b8;font-weight:bold;margin-bottom:4px;">${esc(suite.name)}</div>`;
|
||||
for (const t of suite.tests) {
|
||||
total++;
|
||||
totalTime += t.time;
|
||||
if (t.status === 'pass') {
|
||||
passed++;
|
||||
html += `<div style="color:#4ade80;padding-left:16px;">✓ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||
} else {
|
||||
failed++;
|
||||
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
output.innerHTML = html;
|
||||
summary.textContent = `${passed}/${total} passed, ${failed} failed — ${totalTime}ms`;
|
||||
summary.style.color = failed ? '#fb7185' : '#4ade80';
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
async function run() {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
|
||||
// 1. Create backup
|
||||
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>';
|
||||
summary.textContent = '';
|
||||
await sleep(50);
|
||||
try {
|
||||
await createBackup();
|
||||
} catch (e) {
|
||||
output.innerHTML = `<div style="color:#fb7185;">backup failed: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Stop background sync so tests don't create side effects
|
||||
stopBackgroundSync();
|
||||
window._testRunning = true;
|
||||
|
||||
// 3. Hide panel so tests run against visible UI
|
||||
panel.style.display = 'none';
|
||||
await sleep(100);
|
||||
|
||||
// 4. Run tests
|
||||
const results = await runAll();
|
||||
|
||||
// 5. Clean up any leftover UI state
|
||||
await closeAll();
|
||||
|
||||
// 6. Restore backup
|
||||
try {
|
||||
await restoreBackup();
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
} catch {}
|
||||
|
||||
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
||||
window._testRunning = false;
|
||||
setTimeout(() => startBackgroundSync(), 60000);
|
||||
|
||||
// 8. Show panel with results
|
||||
panel.style.display = '';
|
||||
renderResults(results);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
}
|
||||
|
||||
export function open() {
|
||||
createPanel();
|
||||
run();
|
||||
}
|
||||
|
||||
// ── Auto-activate ──
|
||||
|
||||
if (new URLSearchParams(location.search).has('test')) {
|
||||
setTimeout(open, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
panel ? close() : open();
|
||||
}
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
describe('Search: Tag + Text Combined', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('search with #tag filters by tag', async () => {
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_search_tag__', body: '#searchable', color: '#38bdf8' }),
|
||||
});
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_search_notag__', body: 'no tags here', color: '#38bdf8' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
click('#search-btn');
|
||||
await sleep(150);
|
||||
type('#search-input', '#searchable');
|
||||
await sleep(300);
|
||||
|
||||
const results = document.querySelectorAll('#search-results [data-card-id]');
|
||||
const titles = [...results].map(r => r.querySelector('.font-pixel')?.textContent || r.textContent);
|
||||
const hasTagged = titles.some(t => t.includes('__test_search_tag__'));
|
||||
const hasUntagged = titles.some(t => t.includes('__test_search_notag__'));
|
||||
expect(hasTagged).toBe(true);
|
||||
expect(hasUntagged).toBe(false);
|
||||
});
|
||||
|
||||
it('search with #tag + text filters both', async () => {
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_combo_match__', body: '#searchable combo text', color: '#38bdf8' }),
|
||||
});
|
||||
await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_combo_tagonly__', body: '#searchable no combo', color: '#38bdf8' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
click('#search-btn');
|
||||
await sleep(150);
|
||||
type('#search-input', '#searchable combo');
|
||||
await sleep(300);
|
||||
|
||||
const results = [...document.querySelectorAll('#search-results [data-card-id]')];
|
||||
// The card with "combo text" should rank higher
|
||||
if (results.length >= 2) {
|
||||
const firstTitle = results[0].querySelector('.font-pixel')?.textContent || '';
|
||||
expect(firstTitle).toContain('combo_match');
|
||||
}
|
||||
});
|
||||
|
||||
it('empty search shows recent cards', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(150);
|
||||
type('#search-input', '');
|
||||
$('#search-input').dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
// Should show some cards (recent)
|
||||
const results = document.querySelectorAll('#search-results [data-card-id]');
|
||||
expect(results.length).toBeGreaterThan(-1); // may be 0 if no recent
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search: Keyboard Navigation', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('arrow keys navigate results', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(150);
|
||||
type('#search-input', 'a');
|
||||
await sleep(300);
|
||||
|
||||
const results = document.querySelectorAll('#search-results [data-card-id]');
|
||||
if (results.length < 2) return;
|
||||
|
||||
pressKey('ArrowDown');
|
||||
await sleep(50);
|
||||
// First result should be highlighted
|
||||
const active = document.querySelector('#search-results .search-active, #search-results [data-idx].active');
|
||||
// Just verify no crash
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll } from './runner.js';
|
||||
|
||||
describe('Search', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('opens with Ctrl+K', async () => {
|
||||
pressKey('k', { ctrlKey: true });
|
||||
await sleep(150);
|
||||
expect($('#search')).toBeVisible();
|
||||
});
|
||||
|
||||
it('opens with search button', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(150);
|
||||
expect($('#search')).toBeVisible();
|
||||
});
|
||||
|
||||
it('input is focused on open', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(150);
|
||||
expect(document.activeElement).toBe($('#search-input'));
|
||||
});
|
||||
|
||||
it('has input and results area', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(100);
|
||||
expect($('#search-input')).toExist();
|
||||
expect($('#search-results')).toExist();
|
||||
});
|
||||
|
||||
it('typing triggers results render', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(100);
|
||||
type('#search-input', 'a');
|
||||
await sleep(250);
|
||||
expect($('#search-results')).toExist();
|
||||
});
|
||||
|
||||
it('closes with ESC', async () => {
|
||||
click('#search-btn');
|
||||
await sleep(100);
|
||||
pressKey('Escape');
|
||||
await sleep(150);
|
||||
expect($('#search')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js';
|
||||
|
||||
describe('Settings: Open/Close', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('opens on button click', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(150);
|
||||
expect($('#settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
it('has all 7 tabs', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
const names = $$('.settings-tab').map(t => t.dataset.tab);
|
||||
expect(names.length).toBe(7);
|
||||
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications']) {
|
||||
expect(names).toContain(tab);
|
||||
}
|
||||
});
|
||||
|
||||
it('tab switching shows correct panels', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
for (const tab of ['theme', 'account', 'caldav', 'relay', 'behavior', 'notifications']) {
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === tab));
|
||||
await sleep(50);
|
||||
expect($(`#settings-panel-${tab}`).classList.contains('hidden')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes on X button', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click('#settings-close');
|
||||
await sleep(150);
|
||||
expect($('#settings-overlay')).toBeHidden();
|
||||
});
|
||||
|
||||
it('closes on overlay click', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
$('#settings-overlay').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await sleep(150);
|
||||
expect($('#settings-overlay')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings: Account', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('password fields exist and are masked', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'account'));
|
||||
await sleep(50);
|
||||
for (const id of ['settings-pw-current', 'settings-pw-new', 'settings-pw-confirm', 'settings-pw-save']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
expect($('#settings-pw-current').type).toBe('password');
|
||||
});
|
||||
|
||||
it('vault key field exists', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'account'));
|
||||
await sleep(50);
|
||||
expect($('#settings-vault-key')).toExist();
|
||||
expect($('#settings-vault-save')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings: Behavior', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('has all selects', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'behavior'));
|
||||
await sleep(100);
|
||||
for (const id of ['setting-current-days', 'setting-card-height', 'setting-card-width', 'setting-body-lines', 'setting-click-action']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('has all toggles', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'behavior'));
|
||||
await sleep(100);
|
||||
for (const id of ['setting-show-tags', 'setting-show-thumbnails', 'setting-show-done-badge', 'setting-confirm-delete', 'setting-auto-save']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings: CalDAV', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('has connection fields', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'caldav'));
|
||||
await sleep(50);
|
||||
for (const id of ['caldav-url', 'caldav-user', 'caldav-pass', 'caldav-test', 'caldav-save', 'caldav-sync-now']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings: Relay', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('has relay fields', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'relay'));
|
||||
await sleep(50);
|
||||
for (const id of ['relay-url', 'relay-key', 'relay-save', 'relay-test']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings: Notifications', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('has notification controls', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
click($$('.settings-tab').find(t => t.dataset.tab === 'notifications'));
|
||||
await sleep(100);
|
||||
expect($('#notif-lookahead')).toExist();
|
||||
expect($('#notif-time')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, it, expect, $ } from './runner.js';
|
||||
|
||||
describe('App Shell', () => {
|
||||
it('header with logo', () => {
|
||||
expect($('header')).toExist();
|
||||
expect($('#logo') || $('header h1')).toExist();
|
||||
});
|
||||
|
||||
it('board element with relative positioning', () => {
|
||||
const board = $('#board');
|
||||
expect(board).toExist();
|
||||
expect(board.classList.contains('relative')).toBe(true);
|
||||
});
|
||||
|
||||
it('footer buttons', () => {
|
||||
for (const id of ['settings-btn', 'export-btn', 'import-btn', 'logout-btn', 'export-md-btn', 'import-logseq-btn']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('header buttons', () => {
|
||||
for (const id of ['search-btn', 'calendar-btn', 'gallery-btn', 'add-btn']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('toolbar controls', () => {
|
||||
for (const id of ['bucket-bar', 'board-split-toggle', 'superview-toggle', 'hide-done', 'hide-termine', 'sort-toggle', 'sort-label']) {
|
||||
expect($(`#${id}`)).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('footer card count', () => {
|
||||
const fc = $('#footer-count');
|
||||
expect(fc).toExist();
|
||||
expect(fc.textContent).toContain('card');
|
||||
});
|
||||
|
||||
it('hidden file inputs', () => {
|
||||
expect($('#import-file')).toExist();
|
||||
expect($('#import-logseq-file')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
// Helper: simulate typing a snippet trigger in the editor body
|
||||
function typeSnippet(bodyEl, trigger) {
|
||||
bodyEl.value = trigger;
|
||||
bodyEl.selectionStart = bodyEl.selectionEnd = trigger.length;
|
||||
// Simulate the space key that triggers snippet expansion
|
||||
bodyEl.value = trigger + ' ';
|
||||
bodyEl.selectionStart = bodyEl.selectionEnd = trigger.length + 1;
|
||||
bodyEl.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ' ' }));
|
||||
}
|
||||
|
||||
describe('Snippets: Date & Time', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('?dat inserts current date', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?dat');
|
||||
await sleep(100);
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
expect(body.value).toContain(today);
|
||||
});
|
||||
|
||||
it('?time inserts current time', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?time');
|
||||
await sleep(100);
|
||||
// Should contain HH:MM pattern
|
||||
expect(body.value).toMatch(/\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('?now inserts date and time', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?now');
|
||||
await sleep(100);
|
||||
expect(body.value).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('?week inserts KW number', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?week');
|
||||
await sleep(100);
|
||||
expect(body.value).toMatch(/KW\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snippets: Markdown Helpers', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('?todo inserts checkbox', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?todo');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('- [ ] ');
|
||||
});
|
||||
|
||||
it('?hr inserts horizontal rule', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?hr');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('---');
|
||||
});
|
||||
|
||||
it('?h1 inserts heading', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?h1');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('# ');
|
||||
});
|
||||
|
||||
it('?table inserts table template', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?table');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('| col1 |');
|
||||
expect(body.value).toContain('| --- |');
|
||||
});
|
||||
|
||||
it('?code inserts code block', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?code');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('```');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snippets: Inline Calculator', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('?3+5 calculates to 8', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?3+5');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('8');
|
||||
});
|
||||
|
||||
it('34+56? becomes 34+56=90', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '34+56?');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('34+56=90');
|
||||
});
|
||||
|
||||
it('?10*3 calculates to 30', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?10*3');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('30');
|
||||
});
|
||||
|
||||
it('?100/4 calculates to 25', async () => {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?100/4');
|
||||
await sleep(100);
|
||||
expect(body.value).toContain('25');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
import { state, sortedCards } from '../modules/state.js';
|
||||
|
||||
describe('Sorting: All Modes', () => {
|
||||
it('manual sort by position', () => {
|
||||
state.sortMode = 'manual';
|
||||
const cards = [
|
||||
{ position: 3, title: 'C', tags: '', created_at: '2024-01-01' },
|
||||
{ position: 1, title: 'A', tags: '', created_at: '2024-01-03' },
|
||||
{ position: 2, title: 'B', tags: '', created_at: '2024-01-02' },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('A');
|
||||
expect(sorted[1].title).toBe('B');
|
||||
expect(sorted[2].title).toBe('C');
|
||||
});
|
||||
|
||||
it('alpha sort by title', () => {
|
||||
state.sortMode = 'alpha';
|
||||
const cards = [
|
||||
{ title: 'Banana', tags: '', created_at: '2024-01-01' },
|
||||
{ title: 'Apple', tags: '', created_at: '2024-01-01' },
|
||||
{ title: 'Cherry', tags: '', created_at: '2024-01-01' },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('Apple');
|
||||
expect(sorted[1].title).toBe('Banana');
|
||||
expect(sorted[2].title).toBe('Cherry');
|
||||
});
|
||||
|
||||
it('newest sort by effective date descending', () => {
|
||||
state.sortMode = 'newest';
|
||||
const cards = [
|
||||
{ title: 'Old', tags: '', created_at: '2024-01-01', due_date: null },
|
||||
{ title: 'New', tags: '', created_at: '2024-06-01', due_date: null },
|
||||
{ title: 'Mid', tags: '', created_at: '2024-03-01', due_date: null },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('New');
|
||||
expect(sorted[2].title).toBe('Old');
|
||||
});
|
||||
|
||||
it('oldest sort by effective date ascending', () => {
|
||||
state.sortMode = 'oldest';
|
||||
const cards = [
|
||||
{ title: 'New', tags: '', created_at: '2024-06-01', due_date: null },
|
||||
{ title: 'Old', tags: '', created_at: '2024-01-01', due_date: null },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('Old');
|
||||
expect(sorted[1].title).toBe('New');
|
||||
});
|
||||
|
||||
it('due_date takes priority over created_at in date sort', () => {
|
||||
state.sortMode = 'newest';
|
||||
const cards = [
|
||||
{ title: 'OldCreated_NewDue', tags: '', created_at: '2020-01-01', due_date: '2099-12-31' },
|
||||
{ title: 'NewCreated_NoDue', tags: '', created_at: '2024-06-01', due_date: null },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('OldCreated_NewDue'); // due_date 2099 > created 2024
|
||||
});
|
||||
|
||||
it('date title takes priority over created_at', () => {
|
||||
state.sortMode = 'newest';
|
||||
const cards = [
|
||||
{ title: '2099-06-15 Future', tags: '', created_at: '2020-01-01', due_date: null },
|
||||
{ title: 'Recent card', tags: '', created_at: '2024-06-01', due_date: null },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('2099-06-15 Future');
|
||||
});
|
||||
|
||||
it('tag sort groups by first tag', () => {
|
||||
state.sortMode = 'tag';
|
||||
const cards = [
|
||||
{ title: 'C', tags: 'zebra', created_at: '2024-01-01' },
|
||||
{ title: 'A', tags: 'alpha', created_at: '2024-01-01' },
|
||||
{ title: 'B', tags: '', created_at: '2024-01-01' },
|
||||
];
|
||||
const sorted = sortedCards(cards);
|
||||
expect(sorted[0].title).toBe('A'); // alpha
|
||||
expect(sorted[1].title).toBe('C'); // zebra
|
||||
expect(sorted[2].title).toBe('B'); // no tag → last
|
||||
});
|
||||
|
||||
// Restore
|
||||
it('(cleanup) restore sort mode', () => {
|
||||
state.sortMode = localStorage.getItem('hyprcrd-sort') || 'manual';
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../modules/state.js';
|
||||
|
||||
describe('State: parseTags', () => {
|
||||
it('splits comma-separated tags', () => {
|
||||
expect(parseTags({ tags: 'alpha,beta,gamma' })).toEqual(['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('handles empty tags', () => {
|
||||
expect(parseTags({ tags: '' })).toEqual([]);
|
||||
expect(parseTags({ tags: null })).toEqual([]);
|
||||
expect(parseTags({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single tag', () => {
|
||||
expect(parseTags({ tags: 'solo' })).toEqual(['solo']);
|
||||
});
|
||||
|
||||
it('handles hierarchical tags', () => {
|
||||
expect(parseTags({ tags: 'parent/child,other' })).toEqual(['parent/child', 'other']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State: getChildren / getGroupCards', () => {
|
||||
it('getChildren returns child cards', () => {
|
||||
const parent = state.cards.find(c => {
|
||||
return state.cards.some(ch => ch.parent_id == c.id);
|
||||
});
|
||||
if (!parent) return; // no groups
|
||||
const children = getChildren(parent.id);
|
||||
expect(children.length).toBeGreaterThan(0);
|
||||
for (const ch of children) expect(ch.parent_id).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('getChildren returns empty for non-parent', () => {
|
||||
const orphan = state.cards.find(c => !c.parent_id && !state.cards.some(ch => ch.parent_id == c.id));
|
||||
if (!orphan) return;
|
||||
expect(getChildren(orphan.id).length).toBe(0);
|
||||
});
|
||||
|
||||
it('getGroupCards returns parent + children', () => {
|
||||
const parent = state.cards.find(c => state.cards.some(ch => ch.parent_id == c.id));
|
||||
if (!parent) return;
|
||||
const group = getGroupCards(parent);
|
||||
expect(group.length).toBeGreaterThan(1);
|
||||
expect(group[0].id).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('getGroupCards returns null for solo card', () => {
|
||||
const solo = state.cards.find(c => !c.parent_id && !state.cards.some(ch => ch.parent_id == c.id));
|
||||
if (!solo) return;
|
||||
expect(getGroupCards(solo)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State: Recent Cards', () => {
|
||||
it('markRecent adds card to recent list', () => {
|
||||
const id = 99999;
|
||||
markRecent(id);
|
||||
expect(getRecent()).toContain(String(id));
|
||||
// Clean up
|
||||
const r = getRecent().filter(i => i != id);
|
||||
localStorage.setItem('hyprcrd-recent', JSON.stringify(r));
|
||||
});
|
||||
|
||||
it('markRecent puts card at front', () => {
|
||||
markRecent(88881);
|
||||
markRecent(88882);
|
||||
const r = getRecent();
|
||||
expect(r[0]).toBe('88882');
|
||||
expect(r[1]).toBe('88881');
|
||||
// Clean up
|
||||
const cleaned = r.filter(i => i != '88881' && i != '88882');
|
||||
localStorage.setItem('hyprcrd-recent', JSON.stringify(cleaned));
|
||||
});
|
||||
|
||||
it('markRecent deduplicates', () => {
|
||||
markRecent(77771);
|
||||
markRecent(77772);
|
||||
markRecent(77771);
|
||||
const r = getRecent();
|
||||
const count = r.filter(i => i == '77771').length;
|
||||
expect(count).toBe(1);
|
||||
expect(r[0]).toBe('77771');
|
||||
const cleaned = r.filter(i => i != '77771' && i != '77772');
|
||||
localStorage.setItem('hyprcrd-recent', JSON.stringify(cleaned));
|
||||
});
|
||||
|
||||
it('markRecent caps at 10', () => {
|
||||
for (let i = 0; i < 15; i++) markRecent(60000 + i);
|
||||
expect(getRecent().length).toBeLessThan(12);
|
||||
const cleaned = getRecent().filter(i => parseInt(i) < 60000 || parseInt(i) > 60020);
|
||||
localStorage.setItem('hyprcrd-recent', JSON.stringify(cleaned));
|
||||
});
|
||||
});
|
||||
|
||||
describe('State: Favorites', () => {
|
||||
it('toggleFavorite adds and removes', () => {
|
||||
const id = 55555;
|
||||
const wasFav = isFavorite(id);
|
||||
toggleFavorite(id);
|
||||
expect(isFavorite(id)).toBe(!wasFav);
|
||||
toggleFavorite(id);
|
||||
expect(isFavorite(id)).toBe(wasFav);
|
||||
});
|
||||
|
||||
it('isFavorite returns boolean', () => {
|
||||
expect(typeof isFavorite(1)).toBe('boolean');
|
||||
});
|
||||
|
||||
it('getFavorites returns array', () => {
|
||||
expect(Array.isArray(getFavorites())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
// ── hyprcrd Test Index ──
|
||||
// Imports all test suites. Add new test files here.
|
||||
|
||||
import './shell.test.js';
|
||||
import './settings.test.js';
|
||||
import './board.test.js';
|
||||
import './toolbar.test.js';
|
||||
import './buckets.test.js';
|
||||
import './bucket-context.test.js';
|
||||
import './context-menu.test.js';
|
||||
import './card-interactions.test.js';
|
||||
import './filtering.test.js';
|
||||
import './sorting.test.js';
|
||||
import './state-functions.test.js';
|
||||
import './favorites.test.js';
|
||||
import './search.test.js';
|
||||
import './search-logic.test.js';
|
||||
import './calendar.test.js';
|
||||
import './calendar-popup.test.js';
|
||||
import './rrule.test.js';
|
||||
import './datepicker.test.js';
|
||||
import './gallery.test.js';
|
||||
import './help.test.js';
|
||||
import './multiselect.test.js';
|
||||
import './detail.test.js';
|
||||
import './detail-interactions.test.js';
|
||||
import './editor.test.js';
|
||||
import './editor-features.test.js';
|
||||
import './editor-save.test.js';
|
||||
import './editor-toolbar.test.js';
|
||||
import './line-numbers.test.js';
|
||||
import './snippets.test.js';
|
||||
import './image-fullscreen.test.js';
|
||||
import './markdown.test.js';
|
||||
import './markdown-edge.test.js';
|
||||
import './undo.test.js';
|
||||
import './api.test.js';
|
||||
import './api-edge.test.js';
|
||||
import './export.test.js';
|
||||
import './relay.test.js';
|
||||
import './notifications.test.js';
|
||||
import './quick-capture.test.js';
|
||||
import './theme.test.js';
|
||||
@@ -1,268 +0,0 @@
|
||||
# hyprcrd GUI Test Coverage
|
||||
|
||||
Self-contained in-app test suite. No external dependencies.
|
||||
|
||||
## Activation
|
||||
|
||||
- **URL**: append `?test=1`
|
||||
- **Keyboard**: `Ctrl+Shift+T`
|
||||
- **Safety**: automatic backup before run, auto-restore after
|
||||
- **Visibility**: panel hides during tests so you can watch interactions, shows results after
|
||||
- **Sync paused**: background sync and autoSync disabled during tests
|
||||
|
||||
## Test Files (39 files, ~280 tests)
|
||||
|
||||
| File | Focus |
|
||||
|---|---|
|
||||
| `shell.test.js` | Header, footer, toolbar, file inputs |
|
||||
| `settings.test.js` | All 7 tabs, open/close, account, behavior, CalDAV, relay, notifications |
|
||||
| `board.test.js` | Card rendering, split preview, card lifecycle |
|
||||
| `toolbar.test.js` | Filter toggles (done/termine/tagged), superview, sort modes |
|
||||
| `buckets.test.js` | Bucket bar rendering, filtering, create/delete |
|
||||
| `bucket-context.test.js` | Bucket right-click menu, rename, color, delete options, move-left disabled |
|
||||
| `context-menu.test.js` | Card right-click, actions, submenus, close |
|
||||
| `card-interactions.test.js` | Tags, due dates, done badges, overdue, favorites, color, bucket, grouping |
|
||||
| `filtering.test.js` | Tag AND logic, hierarchical match, unsorted bucket, hide filters |
|
||||
| `sorting.test.js` | All 5 sort modes, effectiveDate priority |
|
||||
| `state-functions.test.js` | parseTags, getChildren, getGroupCards, recent, favorites, toggleFavorite |
|
||||
| `favorites.test.js` | Favorites bucket filtering, todo progress bar, detail split pane |
|
||||
| `search.test.js` | Open (Ctrl+K, button), input focus, typing, ESC |
|
||||
| `search-logic.test.js` | Tag+text combo search, empty search recent, keyboard nav |
|
||||
| `calendar.test.js` | Open, month title, navigation, today, close |
|
||||
| `calendar-popup.test.js` | Day click, January/December year wrapping |
|
||||
| `rrule.test.js` | RRULE persistence (yearly, weekly+BYDAY, COUNT, UNTIL) |
|
||||
| `datepicker.test.js` | Rendering, 42-cell grid, today/selected highlight, Monday start, navigation, year wrap, click, double-click submit |
|
||||
| `gallery.test.js` | Open, image count, select-none, close |
|
||||
| `help.test.js` | F1 toggle, button, content sections |
|
||||
| `multiselect.test.js` | Ctrl+click select/deselect, multiple, click-outside clear |
|
||||
| `detail.test.js` | Structure, controls, open, title, ESC, close |
|
||||
| `detail-interactions.test.js` | Done toggle, edit from detail, zen mode, backlinks |
|
||||
| `editor.test.js` | Structure, open/close, card create |
|
||||
| `editor-features.test.js` | Color picker, preview, line numbers, save flow, edit existing |
|
||||
| `editor-save.test.js` | Empty title prevention, Ctrl+S, Tab title→body, update existing |
|
||||
| `editor-toolbar.test.js` | Bold, italic, todo, heading, hr buttons, auto-continue list on Enter |
|
||||
| `snippets.test.js` | All snippets (?dat, ?time, ?now, ?week, ?todo, ?hr, ?h1, ?table, ?code), inline calculator |
|
||||
| `image-fullscreen.test.js` | Overlay structure, bindDetailImageFullscreen export |
|
||||
| `markdown.test.js` | Basic rendering, wiki links (resolve, broken, case-insensitive), gallery block, todos, stripMd |
|
||||
| `markdown-edge.test.js` | Images/headings/lists/code/hr/todos/links after blank lines, multi-paragraph spacing |
|
||||
| `undo.test.js` | Color, title, body, due_date revert, delete+recreate, parent_id, bucket, multiple undos, empty stack |
|
||||
| `api.test.js` | Cards CRUD, buckets CRUD, auth & misc endpoints |
|
||||
| `api-edge.test.js` | XSS in title, empty body, null due_date, nonexistent card, empty bucket name, CSRF, positions |
|
||||
| `export.test.js` | JSON ZIP, vault ZIP, round-trip integrity (card count, caldav_uid, tags, due_dates) |
|
||||
| `relay.test.js` | Push/poll API (unconfigured state) |
|
||||
| `notifications.test.js` | Banner element, localStorage, settings selects |
|
||||
| `theme.test.js` | CSS vars, localStorage, applyTheme, themes object |
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
### Data Layer
|
||||
- [x] parseTags (comma-split, empty, hierarchical)
|
||||
- [x] getChildren / getGroupCards
|
||||
- [x] getRecent / markRecent (add, front, dedup, cap at 10)
|
||||
- [x] getFavorites / toggleFavorite / isFavorite
|
||||
- [x] sortedCards all 5 modes (manual, newest, oldest, alpha, tag)
|
||||
- [x] effectiveDate priority (due_date > title date > created_at)
|
||||
|
||||
### Board & Cards
|
||||
- [x] Card rendering (data-id, draggable, color bar)
|
||||
- [x] Split preview (open/close, empty state, card preview, active highlight)
|
||||
- [x] Card create appears, delete disappears, delete clears preview
|
||||
- [x] Todo progress bar segments
|
||||
- [x] Overdue danger border
|
||||
- [x] Done badge
|
||||
- [x] Due date pill
|
||||
|
||||
### Toolbar
|
||||
- [x] All filter toggles with active class (done, termine, tagged)
|
||||
- [x] Superview toggle with active class
|
||||
- [x] Sort mode cycling
|
||||
- [x] Superview renders list items
|
||||
|
||||
### Buckets
|
||||
- [x] Bucket bar renders tabs, all special buckets (all, current, journal, favorites, unsorted)
|
||||
- [x] Bucket click filters cards
|
||||
- [x] New bucket appears, deleted disappears
|
||||
- [x] Bucket context menu (rename, color, move-left disabled on first)
|
||||
- [x] Empty bucket: single delete option
|
||||
- [x] Bucket with cards: both delete options
|
||||
- [x] Favorites bucket shows only favorited cards
|
||||
|
||||
### Filtering
|
||||
- [x] Single tag filter
|
||||
- [x] Multiple tags AND logic
|
||||
- [x] Hierarchical tag parent match
|
||||
- [x] Unsorted bucket excludes dated titles
|
||||
- [x] hideTermine excludes caldav cards
|
||||
- [x] hideTagged excludes tagged cards
|
||||
- [x] hideDone excludes #done cards
|
||||
|
||||
### Context Menu
|
||||
- [x] Opens on right-click, closes on click
|
||||
- [x] All actions (open, edit, duplicate, due-date, save-md, push-relay, favorite, color, bucket, tags, delete)
|
||||
- [x] Color submenu presets
|
||||
- [x] Tag submenu with input
|
||||
- [x] Favorite toggle
|
||||
- [x] Color change via menu
|
||||
- [x] Bucket assignment via menu
|
||||
- [x] Viewport-aware positioning (opens upward when near bottom)
|
||||
|
||||
### Search
|
||||
- [x] Opens with Ctrl+K and button
|
||||
- [x] Input focused on open
|
||||
- [x] Typing triggers results
|
||||
- [x] Tag-only filter (#tag)
|
||||
- [x] Tag + text combined
|
||||
- [x] Empty search shows recent
|
||||
- [x] Keyboard navigation
|
||||
- [x] ESC closes
|
||||
|
||||
### Calendar
|
||||
- [x] Open/close
|
||||
- [x] Month title, navigation (prev, next, today)
|
||||
- [x] January→December year wrap
|
||||
- [x] December→January year wrap
|
||||
- [x] Day click
|
||||
|
||||
### Datepicker
|
||||
- [x] Correct month for default value
|
||||
- [x] 42 day cells (6-week grid)
|
||||
- [x] Today highlight, selected highlight
|
||||
- [x] Monday start
|
||||
- [x] Month navigation, Dec→Jan year wrap
|
||||
- [x] Click day selects, double-click submits
|
||||
|
||||
### RRULE
|
||||
- [x] FREQ=YEARLY persistence
|
||||
- [x] FREQ=WEEKLY;BYDAY=MO persistence
|
||||
- [x] COUNT persistence
|
||||
- [x] UNTIL persistence
|
||||
|
||||
### Gallery
|
||||
- [x] Open/close, image count, select-none
|
||||
|
||||
### Help
|
||||
- [x] F1 toggle, button open, content sections
|
||||
|
||||
### Multi-Select
|
||||
- [x] Bar hidden by default, all action buttons
|
||||
- [x] Ctrl+click select/deselect
|
||||
- [x] Multiple cards, click-outside clear
|
||||
|
||||
### Detail View
|
||||
- [x] Structure, controls, open via click, title, ESC, close
|
||||
- [x] Done toggle saves #done tag
|
||||
- [x] Edit opens editor with card data
|
||||
- [x] Zen mode button exists
|
||||
- [x] Backlinks section exists
|
||||
- [x] Split pane button exists
|
||||
|
||||
### Editor
|
||||
- [x] Structure, save buttons, due date, done toggle
|
||||
- [x] Opens (add button, board right-click), closes (cancel, ESC)
|
||||
- [x] Title focused on open
|
||||
- [x] Color presets render and change color
|
||||
- [x] Preview pane and toggle
|
||||
- [x] Line numbers gutter and toggle
|
||||
- [x] Full save flow (title + body → save-close → card exists)
|
||||
- [x] Edit existing loads data, update persists
|
||||
- [x] Empty/whitespace title blocks save
|
||||
- [x] Ctrl+S saves card
|
||||
- [x] Tab moves title → body
|
||||
- [x] Toolbar: bold, italic, todo, heading, hr buttons
|
||||
- [x] Auto-continue bullet list on Enter
|
||||
- [x] Auto-continue todo list on Enter
|
||||
- [x] Empty bullet cleared on Enter
|
||||
|
||||
### Snippets & Calculator
|
||||
- [x] All snippets: ?dat, ?time, ?now, ?week, ?todo, ?hr, ?h1, ?table, ?code
|
||||
- [x] Inline calculator: ?3+5→8, 34+56?→34+56=90, multiplication, division
|
||||
|
||||
### Markdown
|
||||
- [x] Paragraphs, bold, italic, headings, code blocks
|
||||
- [x] External links with target=_blank
|
||||
- [x] Wiki links: resolve, broken, case-insensitive
|
||||
- [x] Gallery fenced block, empty gallery
|
||||
- [x] formatTodoItems, checked items
|
||||
- [x] stripMd
|
||||
- [x] Images/headings/lists/code/hr/todos/wiki-links after blank lines
|
||||
- [x] Multiple blank lines spacing, images between blank lines
|
||||
|
||||
### Undo
|
||||
- [x] Color change revert
|
||||
- [x] Title, body, due_date revert from editor
|
||||
- [x] Delete → recreate with body, color
|
||||
- [x] Delete preserves parent_id
|
||||
- [x] Bucket assignment revert
|
||||
- [x] Multiple sequential undos (stack order)
|
||||
- [x] Empty stack no-op
|
||||
|
||||
### API
|
||||
- [x] Cards: GET, GET/meta, POST, PATCH (returns card), DELETE (returns {ok})
|
||||
- [x] Tag extraction, due_date persistence, bucket assignment
|
||||
- [x] Buckets: GET, POST, PATCH (returns bucket), DELETE
|
||||
- [x] Auth status, relay settings, caldav settings, 404
|
||||
- [x] XSS in title, empty body, null due_date, nonexistent card
|
||||
- [x] Empty bucket name error, CSRF required, GET no CSRF
|
||||
- [x] Position create and update
|
||||
|
||||
### Export/Import
|
||||
- [x] JSON ZIP endpoint (status, content-type, ZIP magic)
|
||||
- [x] Vault export endpoint
|
||||
- [x] Round-trip: card count preserved
|
||||
- [x] Round-trip: caldav_uid preserved
|
||||
- [x] Round-trip: tags and due_dates preserved
|
||||
- [x] parent_id correctly remapped on import
|
||||
|
||||
### Relay
|
||||
- [x] Push without config → error
|
||||
- [x] Push without card_id → 400
|
||||
- [x] Poll without config → 0 pulled
|
||||
|
||||
### Notifications
|
||||
- [x] Banner element, settings in localStorage
|
||||
- [x] Lookahead and time selects
|
||||
|
||||
### Theme
|
||||
- [x] CSS custom properties (8 vars)
|
||||
- [x] localStorage persistence
|
||||
- [x] applyTheme function, themes object
|
||||
- [x] Card layout CSS vars
|
||||
|
||||
## Not Yet Covered
|
||||
|
||||
- Drag and drop (card reorder, bucket reorder, search-to-board)
|
||||
- Box-select rubber band visual
|
||||
- CalDAV sync flow (requires server)
|
||||
- Relay push/poll with configured relay (requires relay)
|
||||
- File upload (paste, drag into editor)
|
||||
- Image fullscreen zoom/pan interaction
|
||||
- Zen mode resize handles drag
|
||||
- Notification banner dismiss click
|
||||
- Calendar hover card popups
|
||||
- Calendar appointment/journal creation via double-click
|
||||
- Editor divider drag resize
|
||||
- Editor image resize handles
|
||||
- Superview hover popup system
|
||||
- Detail sidebar card navigation
|
||||
- Detail image gallery prev/next navigation
|
||||
|
||||
## Bugs Found & Fixed by Tests
|
||||
|
||||
1. **Import lost all CalDAV fields** — caldav_uid, tags, due_date, parent_id, relay_uid not preserved on import → CalDAV sync created duplicates
|
||||
2. **Import lost parent_id mapping** — grouped cards lost their group after export→import (IDs changed)
|
||||
3. **Undo delete lost parent_id** — undo after deleting a grouped card didn't restore group membership
|
||||
4. **Undo editor lost due_date** — undo after editing didn't restore previous due_date
|
||||
5. **_reloadAll didn't reload buckets** — new/deleted buckets not reflected without manual page reload
|
||||
6. **Blank lines broke images in markdown** — `<br>\n` before `![img]()` prevented rendering
|
||||
7. **Context menu clipped at viewport bottom** — fixed position calculation, now opens upward
|
||||
|
||||
## Adding Tests
|
||||
|
||||
1. Create `yourfeature.test.js` in `public/js/test/`
|
||||
2. Import runner: `import { describe, it, expect, ... } from './runner.js'`
|
||||
3. Add import to `tests.js`
|
||||
4. Update this file
|
||||
|
||||
Tests don't need manual cleanup — backup is auto-restored after every run.
|
||||
Use `csrf()` for API calls. Use `closeAll()` in `afterEach` for UI state.
|
||||
Sync is paused during test runs (`window._testRunning`).
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
|
||||
describe('Theme', () => {
|
||||
it('CSS custom properties set', () => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
for (const prop of ['--t-bg', '--t-accent', '--t-text', '--t-surface', '--t-border', '--t-muted', '--t-dim', '--t-bright']) {
|
||||
expect(style.getPropertyValue(prop).trim()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('theme persisted in localStorage', () => {
|
||||
expect(localStorage.getItem('hyprcrd-theme')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applyTheme function exists', () => {
|
||||
expect(typeof window.applyTheme).toBe('function');
|
||||
});
|
||||
|
||||
it('themes object available', () => {
|
||||
expect(window.themes).toExist();
|
||||
expect(Object.keys(window.themes).length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('card layout CSS vars set', () => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
expect(style.getPropertyValue('--card-min-h').trim()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcuts', () => {
|
||||
it('Ctrl+Shift+T bound (test runner)', () => {
|
||||
// Just verify we got here — the test runner itself proves this works
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
|
||||
describe('Toolbar: Filter Toggles', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('hide-done toggles state and active class', async () => {
|
||||
const before = state.hideDone;
|
||||
click('#hide-done');
|
||||
await sleep(100);
|
||||
expect(state.hideDone).toBe(!before);
|
||||
expect($('#hide-done').classList.contains('active')).toBe(!before);
|
||||
// Reset
|
||||
click('#hide-done');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('hide-termine toggles state and active class', async () => {
|
||||
const before = state.hideTermine;
|
||||
click('#hide-termine');
|
||||
await sleep(100);
|
||||
expect(state.hideTermine).toBe(!before);
|
||||
expect($('#hide-termine').classList.contains('active')).toBe(!before);
|
||||
click('#hide-termine');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('hide-tagged toggles state and active class', async () => {
|
||||
const before = state.hideTagged;
|
||||
click('#hide-tagged');
|
||||
await sleep(100);
|
||||
expect(state.hideTagged).toBe(!before);
|
||||
expect($('#hide-tagged').classList.contains('active')).toBe(!before);
|
||||
click('#hide-tagged');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('superview toggles state and active class', async () => {
|
||||
const before = state.superView;
|
||||
click('#superview-toggle');
|
||||
await sleep(100);
|
||||
expect(state.superView).toBe(!before);
|
||||
expect($('#superview-toggle').classList.contains('active')).toBe(!before);
|
||||
click('#superview-toggle');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('sort cycles through modes', async () => {
|
||||
const modes = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
||||
const before = state.sortMode;
|
||||
click('#sort-toggle');
|
||||
await sleep(100);
|
||||
const after = state.sortMode;
|
||||
const idx = modes.indexOf(before);
|
||||
expect(after).toBe(modes[(idx + 1) % modes.length]);
|
||||
// Reset back
|
||||
while (state.sortMode !== before) { click('#sort-toggle'); await sleep(50); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toolbar: Superview Rendering', () => {
|
||||
afterEach(async () => {
|
||||
if (state.superView) { click('#superview-toggle'); await sleep(100); }
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('superview renders list items', async () => {
|
||||
if (state.superView) { click('#superview-toggle'); await sleep(100); }
|
||||
click('#superview-toggle');
|
||||
await sleep(200);
|
||||
const items = document.querySelectorAll('.superview-item');
|
||||
expect(items.length).toBeGreaterThan(-1);
|
||||
});
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js';
|
||||
|
||||
function ctrlZ() {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||
}
|
||||
|
||||
describe('Undo: Color Change', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Ctrl+Z reverts color via context menu', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_color__', 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($$('#ctx-items [data-action="color"]')[0]);
|
||||
await sleep(100);
|
||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
||||
await sleep(300);
|
||||
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.color).toBe('#fb7185');
|
||||
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.color).toBe('#38bdf8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo: Editor Save', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Ctrl+Z reverts title change', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_title_orig__', body: 'original body', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Open editor via context menu
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
await sleep(150);
|
||||
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
||||
await sleep(300);
|
||||
|
||||
// Change title
|
||||
type('#inp-title', '__test_undo_title_changed__');
|
||||
click('#modal-save-close');
|
||||
await sleep(400);
|
||||
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.title).toBe('__test_undo_title_changed__');
|
||||
|
||||
// Undo
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.title).toBe('__test_undo_title_orig__');
|
||||
});
|
||||
|
||||
it('Ctrl+Z reverts body change', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_body__', body: 'before edit', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
await sleep(150);
|
||||
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
||||
await sleep(300);
|
||||
|
||||
const bodyEl = $('#editor-body');
|
||||
bodyEl.value = 'after edit';
|
||||
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
click('#modal-save-close');
|
||||
await sleep(400);
|
||||
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.body).toContain('after edit');
|
||||
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.body).toContain('before edit');
|
||||
});
|
||||
|
||||
it('Ctrl+Z reverts due_date change', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_due__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
// Set a due date
|
||||
await fetch(`api/cards/${res.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ due_date: '2099-06-15' }),
|
||||
});
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Edit and change due date via editor
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
await sleep(150);
|
||||
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
||||
await sleep(300);
|
||||
|
||||
// Change due date by directly clearing it
|
||||
const dueInput = $('#inp-due-value');
|
||||
if (dueInput) dueInput.value = '';
|
||||
click('#modal-save-close');
|
||||
await sleep(400);
|
||||
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.due_date).toBe(null);
|
||||
|
||||
// Undo should restore the due date
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.due_date).toBe('2099-06-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo: Card Delete', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Ctrl+Z after delete recreates card with body and color', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_del__', body: 'restore me #testtag', color: '#fb7185' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Delete via context menu
|
||||
// First disable confirm-delete
|
||||
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
||||
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
||||
await sleep(150);
|
||||
click(document.querySelector('#ctx-items [data-action="delete"]'));
|
||||
await sleep(400);
|
||||
|
||||
// Verify deleted
|
||||
let cards = await fetch('api/cards').then(r => r.json());
|
||||
expect(cards.some(c => c.id == res.id)).toBe(false);
|
||||
|
||||
// Undo
|
||||
ctrlZ();
|
||||
await sleep(500);
|
||||
|
||||
// Card should be recreated (with new ID)
|
||||
cards = await fetch('api/cards').then(r => r.json());
|
||||
const restored = cards.find(c => c.title === '__test_undo_del__');
|
||||
expect(restored).toExist();
|
||||
expect(restored.body).toContain('restore me');
|
||||
expect(restored.color).toBe('#fb7185');
|
||||
|
||||
// Restore confirm setting
|
||||
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||
});
|
||||
|
||||
it('Ctrl+Z after delete preserves parent_id', async () => {
|
||||
const parent = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_del_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_undo_del_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
||||
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
||||
|
||||
// Delete the child (it may not be directly visible, delete via API + push undo manually)
|
||||
// Simpler: delete via API and test if undo restores parent_id
|
||||
// Actually we need to use the context menu to get the undo pushed
|
||||
// The child might be inside a group view — let's just test via the undo module directly
|
||||
|
||||
// Import undo and test directly
|
||||
const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../modules/undo.js');
|
||||
const snap = { ...child, parent_id: parent.id };
|
||||
pushCardDelete('test-del', snap);
|
||||
await fetch(`api/cards/${child.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
||||
|
||||
// Undo
|
||||
await undo();
|
||||
await sleep(300);
|
||||
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
const restored = cards.find(c => c.title === '__test_undo_del_child__');
|
||||
expect(restored).toExist();
|
||||
expect(restored.parent_id).toBe(parent.id);
|
||||
|
||||
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo: Bucket Change', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('Ctrl+Z reverts bucket assignment via context menu', async () => {
|
||||
const bucket = await fetch('api/buckets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name: '__test_undo_bucket__' }),
|
||||
}).then(r => r.json());
|
||||
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_bkt__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
|
||||
// Original: no bucket
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.bucket_id).toBe(null);
|
||||
|
||||
// Assign bucket via context menu
|
||||
rightClick(cardEl);
|
||||
await sleep(150);
|
||||
click($$('#ctx-items [data-action="bucket"]')[0]);
|
||||
await sleep(100);
|
||||
const bktBtn = $$('#ctx-buckets [data-ctx-bucket]').find(b => b.dataset.ctxBucket == bucket.id);
|
||||
if (bktBtn) click(bktBtn);
|
||||
await sleep(300);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.bucket_id).toBe(bucket.id);
|
||||
|
||||
// Undo
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.bucket_id).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo: Multiple Undos', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('multiple Ctrl+Z reverts in stack order', async () => {
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_undo_multi__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
|
||||
// Change 1: color to pink
|
||||
rightClick(cardEl);
|
||||
await sleep(150);
|
||||
click($$('#ctx-items [data-action="color"]')[0]);
|
||||
await sleep(100);
|
||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
||||
await sleep(300);
|
||||
|
||||
// Change 2: color to green
|
||||
const cardEl2 = document.querySelector(`[data-id="${res.id}"]`);
|
||||
rightClick(cardEl2);
|
||||
await sleep(150);
|
||||
click($$('#ctx-items [data-action="color"]')[0]);
|
||||
await sleep(100);
|
||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]'));
|
||||
await sleep(300);
|
||||
|
||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.color).toBe('#4ade80');
|
||||
|
||||
// Undo 1: back to pink
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.color).toBe('#fb7185');
|
||||
|
||||
// Undo 2: back to original blue
|
||||
ctrlZ();
|
||||
await sleep(400);
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
expect(card.color).toBe('#38bdf8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo: No-Op When Empty', () => {
|
||||
it('Ctrl+Z with empty stack does nothing', async () => {
|
||||
const { canUndo } = await import('../modules/undo.js');
|
||||
// Clear stack by undoing everything
|
||||
while (canUndo()) {
|
||||
ctrlZ();
|
||||
await sleep(200);
|
||||
}
|
||||
// Now Ctrl+Z should be harmless
|
||||
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
||||
ctrlZ();
|
||||
await sleep(200);
|
||||
const cardsAfter = await fetch('api/cards').then(r => r.json());
|
||||
expect(cardsAfter.length).toBe(cardsBefore.length);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user