UI-tests und fixes aus tests
This commit is contained in:
1 parent
73da8fb849
commit
968d980be1
46 files changed
+3853
-61
No files matched your search
@@ -1,6 +1,16 @@
|
||||
body { font-family: 'JetBrains Mono', monospace; }
|
||||
.font-pixel { font-family: var(--t-font-pixel, 'Silkscreen', cursive) !important; }
|
||||
|
||||
/* Toolbar toggles */
|
||||
.toolbar-toggle {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; font-size: 10px; font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
color: var(--t-dim); border-radius: 3px; border: none; background: none;
|
||||
cursor: pointer; transition: color 0.15s, background 0.15s; flex-shrink: 0; margin-left: 4px;
|
||||
}
|
||||
.toolbar-toggle:hover { color: var(--t-accent); background: var(--t-accent10, rgba(56,189,248,0.08)); }
|
||||
.toolbar-toggle.active { color: var(--t-accent); background: var(--t-accent10, rgba(56,189,248,0.12)); }
|
||||
|
||||
/* Calendar */
|
||||
.cal-header { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 3px solid var(--t-border); flex-shrink: 0; }
|
||||
.cal-day-label { padding: 6px 8px; font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 9px; color: var(--t-dim); text-align: center; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
+29
-12
@@ -599,16 +599,30 @@ if (str_starts_with($route, '/api/')) {
|
||||
$bucketMap[$oldId] = $db->lastInsertId();
|
||||
}
|
||||
|
||||
// Import cards
|
||||
// Import cards (preserve all fields including CalDAV sync state)
|
||||
$cardMap = []; // old id → new id
|
||||
foreach ($data['cards'] as $c) {
|
||||
$oldId = $c['id'] ?? null;
|
||||
$bucketId = ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) ? $bucketMap[$c['bucket_id']] : null;
|
||||
$stmt = $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :created_at, :updated_at)');
|
||||
$stmt = $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags, due_date, caldav_uid, caldav_etag, caldav_href, caldav_synced_at, caldav_rrule, relay_uid, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :tags, :due_date, :caldav_uid, :caldav_etag, :caldav_href, :caldav_synced_at, :caldav_rrule, :relay_uid, :created_at, :updated_at)');
|
||||
$stmt->execute([
|
||||
'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'],
|
||||
'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0,
|
||||
'tags' => $c['tags'] ?? '', 'due_date' => $c['due_date'] ?? null,
|
||||
'caldav_uid' => $c['caldav_uid'] ?? null, 'caldav_etag' => $c['caldav_etag'] ?? null,
|
||||
'caldav_href' => $c['caldav_href'] ?? null, 'caldav_synced_at' => $c['caldav_synced_at'] ?? null,
|
||||
'caldav_rrule' => $c['caldav_rrule'] ?? null, 'relay_uid' => $c['relay_uid'] ?? null,
|
||||
'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'),
|
||||
]);
|
||||
if ($oldId) $cardMap[$oldId] = $db->lastInsertId();
|
||||
}
|
||||
// Resolve parent_id references (old id → new id)
|
||||
foreach ($data['cards'] as $c) {
|
||||
if (!empty($c['parent_id']) && isset($cardMap[$c['id']]) && isset($cardMap[$c['parent_id']])) {
|
||||
$db->prepare('UPDATE cards SET parent_id = ? WHERE id = ?')
|
||||
->execute([$cardMap[$c['parent_id']], $cardMap[$c['id']]]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract uploads
|
||||
@@ -1051,20 +1065,23 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
||||
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
|
||||
<button id="board-split-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="split preview">
|
||||
<i class="ph ph-split-horizontal text-xs"></i>
|
||||
<button id="board-split-toggle" class="toolbar-toggle" title="split preview">
|
||||
<i class="ph ph-split-horizontal text-sm"></i>
|
||||
</button>
|
||||
<button id="superview-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="compact list view">
|
||||
<i class="ph ph-list text-xs"></i>
|
||||
<button id="superview-toggle" class="toolbar-toggle" title="compact list view">
|
||||
<i class="ph ph-list text-sm"></i>
|
||||
</button>
|
||||
<button id="hide-done" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="toggle done">
|
||||
<i class="ph ph-check-circle text-xs"></i>
|
||||
<button id="hide-done" class="toolbar-toggle" title="toggle done">
|
||||
<i class="ph ph-check-circle text-sm"></i>
|
||||
</button>
|
||||
<button id="hide-termine" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2" title="toggle appointments">
|
||||
<i class="ph ph-calendar-x text-xs"></i>
|
||||
<button id="hide-termine" class="toolbar-toggle" title="toggle appointments">
|
||||
<i class="ph ph-calendar-x text-sm"></i>
|
||||
</button>
|
||||
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
|
||||
<i class="ph ph-sort-ascending text-xs"></i>
|
||||
<button id="hide-tagged" class="toolbar-toggle" title="toggle tagged cards">
|
||||
<i class="ph ph-hash text-sm"></i>
|
||||
</button>
|
||||
<button id="sort-toggle" class="toolbar-toggle">
|
||||
<i class="ph ph-sort-ascending text-sm"></i>
|
||||
<span id="sort-label">manual</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+12
-1
@@ -36,7 +36,7 @@ window._ungroupCard = ungroupCard;
|
||||
window._getSelectedIds = getSelectedIds;
|
||||
window._clearSelection = clearSelection;
|
||||
window._refreshTasks = refreshTasksView;
|
||||
window._reloadAll = async () => { state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
||||
window._extractToCard = async (text) => {
|
||||
if (!text?.trim()) return;
|
||||
const lines = text.trim().split('\n');
|
||||
@@ -232,3 +232,14 @@ document.addEventListener('keydown', e => {
|
||||
// ── Boot ──
|
||||
|
||||
checkAuth();
|
||||
|
||||
// ── Test runner (lazy-loaded, no cost in production) ──
|
||||
if (new URLSearchParams(location.search).has('test') || false) {
|
||||
import('./test/tests.js');
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
import('./test/tests.js').then(() => import('./test/runner.js').then(m => m.open()));
|
||||
}
|
||||
});
|
||||
+30
-39
@@ -314,7 +314,7 @@ function toggleBoardSplit() {
|
||||
localStorage.setItem('hyprcrd-board-split', state.boardSplitOpen ? '1' : '0');
|
||||
boardPreview?.classList.toggle('hidden', !state.boardSplitOpen);
|
||||
const btn = $('board-split-toggle');
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.boardSplitOpen);
|
||||
if (btn) btn.classList.toggle('active', state.boardSplitOpen);
|
||||
if (!state.boardSplitOpen) {
|
||||
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
|
||||
state.boardPreviewCardId = null;
|
||||
@@ -332,7 +332,7 @@ $('board-preview-edit')?.addEventListener('click', () => {
|
||||
if (state.boardSplitOpen) {
|
||||
boardPreview?.classList.remove('hidden');
|
||||
const btn = $('board-split-toggle');
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
|
||||
$('superview-toggle')?.addEventListener('click', () => {
|
||||
@@ -340,49 +340,36 @@ $('superview-toggle')?.addEventListener('click', () => {
|
||||
localStorage.setItem('hyprcrd-superview', state.superView ? '1' : '0');
|
||||
const btn = $('superview-toggle');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.superView ? 'ph-squares-four' : 'ph-list'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.superView);
|
||||
if (icon) icon.className = `ph ${state.superView ? 'ph-squares-four' : 'ph-list'} text-sm`;
|
||||
if (btn) btn.classList.toggle('active', state.superView);
|
||||
renderBoard();
|
||||
});
|
||||
if (state.superView) {
|
||||
const btn = $('superview-toggle');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-squares-four text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
if (icon) icon.className = 'ph ph-squares-four text-sm';
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
|
||||
$('hide-done')?.addEventListener('click', () => {
|
||||
state.hideDone = !state.hideDone;
|
||||
localStorage.setItem('hyprcrd-hide-done', state.hideDone ? '1' : '0');
|
||||
const btn = $('hide-done');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.hideDone ? 'ph-check-square' : 'ph-check-circle'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.hideDone);
|
||||
renderBoard();
|
||||
});
|
||||
if (state.hideDone) {
|
||||
const btn = $('hide-done');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-check-square text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
}
|
||||
|
||||
$('hide-termine')?.addEventListener('click', () => {
|
||||
state.hideTermine = !state.hideTermine;
|
||||
localStorage.setItem('hyprcrd-hide-termine', state.hideTermine ? '1' : '0');
|
||||
const btn = $('hide-termine');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state.hideTermine ? 'ph-calendar-check' : 'ph-calendar-x'} text-xs`;
|
||||
if (btn) btn.classList.toggle('text-t-accent', state.hideTermine);
|
||||
renderBoard();
|
||||
});
|
||||
// Init toggle state
|
||||
if (state.hideTermine) {
|
||||
const btn = $('hide-termine');
|
||||
const icon = btn?.querySelector('i');
|
||||
if (icon) icon.className = 'ph ph-calendar-check text-xs';
|
||||
if (btn) btn.classList.add('text-t-accent');
|
||||
function initToggle(id, stateKey, lsKey, iconOff, iconOn) {
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
const apply = () => {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) icon.className = `ph ${state[stateKey] ? iconOn : iconOff} text-sm`;
|
||||
btn.classList.toggle('active', state[stateKey]);
|
||||
};
|
||||
btn.addEventListener('click', () => {
|
||||
state[stateKey] = !state[stateKey];
|
||||
localStorage.setItem(lsKey, state[stateKey] ? '1' : '0');
|
||||
apply();
|
||||
renderBoard();
|
||||
});
|
||||
apply();
|
||||
}
|
||||
initToggle('hide-done', 'hideDone', 'hyprcrd-hide-done', 'ph-check-circle', 'ph-check-square');
|
||||
initToggle('hide-termine', 'hideTermine', 'hyprcrd-hide-termine', 'ph-calendar-x', 'ph-calendar-check');
|
||||
initToggle('hide-tagged', 'hideTagged', 'hyprcrd-hide-tagged', 'ph-hash', 'ph-hash-straight');
|
||||
|
||||
$('sort-toggle')?.addEventListener('click', () => {
|
||||
state.sortMode = SORT_MODES[(SORT_MODES.indexOf(state.sortMode) + 1) % SORT_MODES.length];
|
||||
@@ -485,14 +472,18 @@ export function renderBoard() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide appointments + birthdays
|
||||
// Hide appointments + birthdays (tag-based and caldav-synced)
|
||||
if (state.hideTermine) {
|
||||
cards = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag'); });
|
||||
cards = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag') && !c.caldav_uid; });
|
||||
}
|
||||
// Hide done
|
||||
if (state.hideDone) {
|
||||
cards = cards.filter(c => !parseTags(c).includes('done'));
|
||||
}
|
||||
// Hide tagged (cards with any tags)
|
||||
if (state.hideTagged) {
|
||||
cards = cards.filter(c => !c.tags);
|
||||
}
|
||||
// Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
|
||||
if (state.activeTags.length) {
|
||||
cards = cards.filter(c => {
|
||||
|
||||
@@ -238,6 +238,7 @@ $('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||
// Auto-sync throttle
|
||||
let lastAutoSync = 0;
|
||||
export function autoSync() {
|
||||
if (window._testRunning) return;
|
||||
const now = Date.now();
|
||||
if (now - lastAutoSync < 60000) return;
|
||||
lastAutoSync = now;
|
||||
@@ -288,3 +289,6 @@ export function startBackgroundSync() {
|
||||
backgroundSync();
|
||||
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
|
||||
}
|
||||
export function stopBackgroundSync() {
|
||||
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
|
||||
}
|
||||
@@ -155,9 +155,12 @@ export function showContextMenu(x, y, card) {
|
||||
});
|
||||
tags.classList.add('hidden');
|
||||
|
||||
menu.style.left = Math.min(x, innerWidth - 188) + 'px';
|
||||
menu.style.top = Math.min(y, innerHeight - 300) + 'px';
|
||||
menu.style.left = '0px';
|
||||
menu.style.top = '0px';
|
||||
menu.classList.remove('hidden');
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
|
||||
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
|
||||
|
||||
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ async function doSave(close, { animate = true } = {}) {
|
||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||
if (state.editingId) {
|
||||
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
||||
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id });
|
||||
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
|
||||
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
||||
} else {
|
||||
if (state.activeGroup) {
|
||||
|
||||
@@ -99,10 +99,14 @@ export function renderMd(src) {
|
||||
// Help parser: ensure a list can start after text without requiring manual blank line
|
||||
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
|
||||
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
|
||||
.replace(/\n{3,}/g, m => '\n\n' + '<br>\n'.repeat(m.length - 2))
|
||||
// Ensure hr rules have one clean blank line before and after, keep extra <br> beyond that
|
||||
.replace(/<br>\n(---+|___+|\*\*\*+)\n/gm, '\n\n$1\n')
|
||||
.replace(/(---+|___+|\*\*\*+)\n<br>/gm, '$1\n\n');
|
||||
// Insert <br> BETWEEN paragraphs but not before block-level elements
|
||||
.replace(/\n{3,}/g, m => {
|
||||
const extra = m.length - 2;
|
||||
return '\n\n' + '<br class="spacer">\n\n'.repeat(extra > 3 ? 3 : extra);
|
||||
})
|
||||
// Ensure hr rules have clean blank lines
|
||||
.replace(/<br class="spacer">\n\n(---+|___+|\*\*\*+)/gm, '\n\n$1')
|
||||
.replace(/(---+|___+|\*\*\*+)\n\n<br class="spacer">/gm, '$1\n\n');
|
||||
|
||||
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
|
||||
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
|
||||
|
||||
@@ -14,6 +14,7 @@ export const state = {
|
||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
|
||||
hideDone: localStorage.getItem('hyprcrd-hide-done') === '1',
|
||||
hideTagged: localStorage.getItem('hyprcrd-hide-tagged') === '1',
|
||||
superView: localStorage.getItem('hyprcrd-superview') === '1',
|
||||
|
||||
// Board split preview
|
||||
|
||||
@@ -40,10 +40,14 @@ export function pushCardEdit(label, cardId, prevData) {
|
||||
|
||||
export function pushCardDelete(label, card) {
|
||||
pushUndo(label, async () => {
|
||||
await api('api/cards', { method: 'POST', body: {
|
||||
const created = await api('api/cards', { method: 'POST', body: {
|
||||
title: card.title, body: card.body, color: card.color,
|
||||
bucket_id: card.bucket_id, position: card.position,
|
||||
bucket_id: card.bucket_id, parent_id: card.parent_id, position: card.position,
|
||||
}});
|
||||
// Restore fields that POST doesn't accept
|
||||
if (created?.id && card.due_date) {
|
||||
await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: card.due_date } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
// ── 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;inset:0;z-index:9999;background:rgba(2,6,23,0.95);display:flex;flex-direction:column;font-family:monospace;color:#cbd5e1;overflow:hidden;';
|
||||
panel.innerHTML = `
|
||||
<div style="padding:12px 16px;border-bottom:1px solid #334155;display:flex;align-items:center;gap:12px;flex-shrink:0;">
|
||||
<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="flex:1;overflow-y:scroll;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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// ── 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 './context-menu.test.js';
|
||||
import './card-interactions.test.js';
|
||||
import './filtering.test.js';
|
||||
import './sorting.test.js';
|
||||
import './search.test.js';
|
||||
import './search-logic.test.js';
|
||||
import './calendar.test.js';
|
||||
import './calendar-popup.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 './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 './theme.test.js';
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Suites | Tests | Focus |
|
||||
|---|---|---|---|
|
||||
| `shell.test.js` | 1 | 7 | Header, footer, toolbar, file inputs |
|
||||
| `settings.test.js` | 6 | 11 | All 7 tabs, open/close, account, behavior, CalDAV, relay, notifications |
|
||||
| `board.test.js` | 3 | 11 | Card rendering, split preview, card lifecycle |
|
||||
| `toolbar.test.js` | 2 | 7 | Filter toggles (done/termine/tagged), superview, sort modes |
|
||||
| `buckets.test.js` | 2 | 8 | Bucket bar, filtering, create/delete appearance |
|
||||
| `context-menu.test.js` | 1 | 5 | Right-click, actions, submenus, close |
|
||||
| `card-interactions.test.js` | 4 | 10 | Tags, due dates, done badges, favorites, color, bucket, grouping |
|
||||
| `search.test.js` | 1 | 6 | Open (Ctrl+K, button), input focus, typing, ESC |
|
||||
| `calendar.test.js` | 1 | 7 | Open, month title, navigation, today, close |
|
||||
| `gallery.test.js` | 1 | 5 | Open, image count, select-none, close |
|
||||
| `help.test.js` | 1 | 4 | F1 toggle, button, content sections |
|
||||
| `multiselect.test.js` | 1 | 5 | Ctrl+click select/deselect, multiple, click-outside clear |
|
||||
| `detail.test.js` | 1 | 6 | Structure, controls, open, title, ESC, close |
|
||||
| `detail-interactions.test.js` | 4 | 4 | Done toggle, edit from detail, zen mode, backlinks |
|
||||
| `editor.test.js` | 3 | 8 | Structure, open/close, card create |
|
||||
| `editor-features.test.js` | 5 | 7 | Color picker, preview, line numbers, save flow, edit existing |
|
||||
| `api.test.js` | 3 | 10 | Cards CRUD, buckets CRUD, auth & misc endpoints |
|
||||
| `relay.test.js` | 2 | 3 | Push API, poll API (unconfigured state) |
|
||||
| `notifications.test.js` | 2 | 4 | Banner element, localStorage, settings selects |
|
||||
| `theme.test.js` | 2 | 6 | CSS vars, localStorage, applyTheme, themes object |
|
||||
|
||||
**Total: 21 files, ~45 suites, ~150 tests**
|
||||
|
||||
## Coverage by Area
|
||||
|
||||
### App Shell
|
||||
- [x] Header with logo
|
||||
- [x] Board element (relative positioning)
|
||||
- [x] All footer buttons (logout, import, export, logseq, vault, settings)
|
||||
- [x] All header buttons (search, calendar, gallery, add)
|
||||
- [x] Toolbar controls (split, superview, hide-done, hide-termine, hide-tagged, sort)
|
||||
- [x] Footer card count
|
||||
- [x] Hidden file inputs
|
||||
|
||||
### Toolbar Toggles
|
||||
- [x] hide-done toggles state and `.active` class
|
||||
- [x] hide-termine toggles state and `.active` class
|
||||
- [x] hide-tagged toggles state and `.active` class
|
||||
- [x] superview toggles state and `.active` class
|
||||
- [x] sort cycles through all modes (manual, newest, oldest, alpha, tag)
|
||||
- [x] superview renders list items
|
||||
|
||||
### Bucket Bar
|
||||
- [x] Renders bucket tabs
|
||||
- [x] "all" bucket exists
|
||||
- [x] Clicking bucket filters cards
|
||||
- [x] "current" bucket works
|
||||
- [x] "journal" bucket works
|
||||
- [x] "unsorted" bucket works
|
||||
- [x] New bucket appears in bar
|
||||
- [x] Deleted bucket disappears from bar
|
||||
|
||||
### Settings Panel
|
||||
- [x] Opens on button click
|
||||
- [x] All 7 tabs exist (branding, account, theme, caldav, relay, behavior, notifications)
|
||||
- [x] Tab switching shows correct panels
|
||||
- [x] Closes on X button
|
||||
- [x] Closes on overlay background click
|
||||
- [x] Account: password fields exist and masked
|
||||
- [x] Account: vault key field
|
||||
- [x] Behavior: all selects (current-days, card-height, card-width, body-lines, click-action)
|
||||
- [x] Behavior: all toggles (show-tags, show-thumbnails, show-done-badge, confirm-delete, auto-save)
|
||||
- [x] CalDAV: connection fields (url, user, pass, test, save, sync-now)
|
||||
- [x] Relay: fields (url, key, save, test)
|
||||
- [x] Notifications: lookahead, time controls
|
||||
|
||||
### Board
|
||||
- [x] Cards render with data-id
|
||||
- [x] Cards are draggable
|
||||
- [x] Cards have color bar
|
||||
- [x] Valid data-id attributes
|
||||
- [x] Split preview opens/closes
|
||||
- [x] Split preview empty state
|
||||
- [x] Split preview shows card on click
|
||||
- [x] Active card highlighted in split mode
|
||||
- [x] New card appears on board after create
|
||||
- [x] Deleted card gone from board
|
||||
- [x] Deleted card clears split preview
|
||||
|
||||
### Card Interactions
|
||||
- [x] Tag chips visible (if setting on)
|
||||
- [x] Tag click filters board
|
||||
- [x] Tag right-click removes tag
|
||||
- [x] Due date shows due pill
|
||||
- [x] Done tag shows done badge
|
||||
- [x] Overdue card has danger border
|
||||
- [x] Favorite toggles via context menu
|
||||
- [x] Color change via context menu
|
||||
- [x] Bucket assignment via context menu
|
||||
- [x] Cards can be grouped (parent_id)
|
||||
- [x] Child card hidden under parent on board
|
||||
|
||||
### Context Menu
|
||||
- [x] Opens on right-click
|
||||
- [x] All actions present (open, edit, duplicate, due-date, save-md, push-relay, favorite, color, bucket, tags, delete)
|
||||
- [x] Color submenu with presets
|
||||
- [x] Tag submenu with input
|
||||
- [x] Closes on document click
|
||||
|
||||
### Search
|
||||
- [x] Opens with Ctrl+K
|
||||
- [x] Opens with button
|
||||
- [x] Input focused on open
|
||||
- [x] Input and results area exist
|
||||
- [x] Typing triggers results
|
||||
- [x] Closes with ESC
|
||||
|
||||
### Calendar
|
||||
- [x] Button exists
|
||||
- [x] Opens on click
|
||||
- [x] Month title displayed
|
||||
- [x] Navigation buttons (prev, next, today)
|
||||
- [x] Month navigation works
|
||||
- [x] Today resets to current month
|
||||
- [x] Closes on close button
|
||||
|
||||
### Gallery
|
||||
- [x] Button exists
|
||||
- [x] Opens on click
|
||||
- [x] Grid has user-select: none
|
||||
- [x] Image count shown
|
||||
- [x] Closes on close button
|
||||
|
||||
### Help
|
||||
- [x] Opens with F1
|
||||
- [x] Toggles with F1
|
||||
- [x] Opens with button
|
||||
- [x] Has content sections
|
||||
|
||||
### Multi-Select
|
||||
- [x] Select bar hidden by default
|
||||
- [x] All action buttons (group, bucket, unbucket, color, tag, export, delete, clear)
|
||||
- [x] Ctrl+click selects card
|
||||
- [x] Ctrl+click deselects card
|
||||
- [x] Multiple cards selectable with count
|
||||
- [x] Click outside clears selection
|
||||
|
||||
### Detail View
|
||||
- [x] Structural elements (title, body, meta, tags)
|
||||
- [x] Control buttons (edit, delete, done, zen, close)
|
||||
- [x] Opens on card click (non-split mode)
|
||||
- [x] Shows card title
|
||||
- [x] Closes on ESC
|
||||
- [x] Closes on close button
|
||||
- [x] Done button toggles #done tag
|
||||
- [x] Edit button opens editor with card data
|
||||
- [x] Zen mode button exists
|
||||
- [x] Backlinks section exists
|
||||
|
||||
### Editor
|
||||
- [x] Structural elements (modal, title, body, preview, color presets)
|
||||
- [x] Save buttons (save, save-close, cancel)
|
||||
- [x] Due date and done toggle
|
||||
- [x] Opens on add button
|
||||
- [x] Title focused on open
|
||||
- [x] Closes on cancel
|
||||
- [x] Closes on ESC
|
||||
- [x] Opens on board right-click
|
||||
- [x] Color presets render
|
||||
- [x] Color preset changes card color
|
||||
- [x] Preview pane and toggle exist
|
||||
- [x] Line numbers gutter and toggle exist
|
||||
- [x] Full save flow (type title+body → save-close → card exists)
|
||||
- [x] Edit existing card loads data
|
||||
|
||||
### API Endpoints
|
||||
- [x] GET /api/cards — returns array
|
||||
- [x] GET /api/cards/meta — returns array
|
||||
- [x] POST /api/cards — creates card
|
||||
- [x] PATCH /api/cards/:id — updates (returns card object)
|
||||
- [x] DELETE /api/cards/:id — deletes (returns {ok})
|
||||
- [x] Tag extraction from body
|
||||
- [x] Due date persistence
|
||||
- [x] Bucket assignment
|
||||
- [x] GET /api/buckets — returns array
|
||||
- [x] POST /api/buckets — creates bucket
|
||||
- [x] PATCH /api/buckets/:id — renames (returns bucket object)
|
||||
- [x] DELETE /api/buckets/:id — deletes (returns {ok})
|
||||
- [x] GET /api/auth/status — logged in
|
||||
- [x] GET /api/relay/settings — reachable
|
||||
- [x] GET /api/caldav/settings — reachable
|
||||
- [x] Unknown endpoint returns 404
|
||||
|
||||
### Relay API
|
||||
- [x] Push without config returns error
|
||||
- [x] Push without card_id returns 400
|
||||
- [x] Poll without config returns 0 pulled
|
||||
|
||||
### Notifications
|
||||
- [x] Banner element exists
|
||||
- [x] Settings in localStorage
|
||||
- [x] Lookahead select has options
|
||||
- [x] Time select has options
|
||||
|
||||
### Theme
|
||||
- [x] CSS custom properties set (bg, accent, text, surface, border, muted, dim, bright)
|
||||
- [x] Theme persisted in localStorage
|
||||
- [x] applyTheme function available
|
||||
- [x] Themes object with 5+ themes
|
||||
- [x] Card layout CSS vars
|
||||
|
||||
## Not Yet Covered
|
||||
|
||||
- Drag and drop (card reorder, bucket assignment via drag)
|
||||
- Box-select rubber band
|
||||
- CalDAV sync flow (requires server)
|
||||
- Relay push/poll with configured relay
|
||||
- File upload (paste, drag into editor)
|
||||
- Image fullscreen viewer (zoom, pan)
|
||||
- Zen mode resize handles
|
||||
- Notification banner dismiss
|
||||
- Undo (Ctrl+Z)
|
||||
- Logseq import flow (requires ZIP file)
|
||||
- Export download verification
|
||||
- Datepicker dialog interaction
|
||||
- Editor autocomplete (tags, links, snippets)
|
||||
- Calendar day popup and card creation
|
||||
- Search drag-and-drop to group
|
||||
|
||||
## 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`).
|
||||
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
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