Files
hyprcrd/public/plugins/tests/board-find-replace.test.js
T
thandClaude Opus 4.6 3078906548 find/replace: editor (Ctrl+H/F) + board-wide (Ctrl+Shift+H)
Editor find/replace (public/js/modules/editor.js, public/index.php):
* New #editor-find-bar inside #editor-pane (above the textarea, pushes
  it down via flex-col). Two rows: find input + match count + prev/next
  + close, and replace input + replace-one + replace-all.
* Ctrl+H opens find+replace, Ctrl+F opens find-only (replace row
  hidden). Same shortcut toggles off if already open in the same mode.
* runFind(): case-insensitive indexOf scan, all match positions stored.
  jumpTo() sets textarea.setSelectionRange for the Nth match.
  findNext/findPrev cycle with wrap-around.
* replaceOne(): body.setRangeText at the current match position, re-scan.
  replaceAll(): regex-based global replace on body.value.
* Enter in find input = next (Shift+Enter = prev). Enter in replace
  input = replace one. Escape closes the bar + refocuses textarea.
* closeModal() calls closeFindBar() to clean up on editor close.

Board-wide find/replace (public/js/app.js):
* Ctrl+Shift+H opens a centered overlay that fetches all cards with
  full bodies (api('api/cards'), bypasses lazy-load).
* Live search: typing in the find input scans all card bodies case-
  insensitive. Matches shown as a scrollable list with color dot,
  card title, occurrence count, and a context snippet with <mark>
  highlight around the first match.
* Status line: "12 occurrences in 5 cards" or "no matches".
* Replace All button (danger-styled, disabled until both inputs have
  text and matches exist). Click opens a showDialog confirmation:
  "Replace 12 occurrences of 'foo' with 'bar' across 5 cards?"
  On confirm: regex replace per card body → PATCH → _reloadAll.
* Escape / click-outside / Close button dismiss the overlay.

Shortcuts registered in the central registry for the help screen:
* ⌃ H (editor), ⌃ F (editor), ⌃ ⇧ H (global board-wide).

Tests (public/plugins/tests/board-find-replace.test.js):
* Ctrl+Shift+H opens the overlay (polls for #board-find-replace).
* Typing in find shows matching cards (creates 3 cards, 2 match,
  asserts status count + list contains matching titles + excludes
  non-matching title).
* Replace all updates card bodies after confirmation (creates 2
  cards with "hello", replaces with "goodbye", confirms dialog,
  verifies via API that bodies are "goodbye world goodbye" and
  "goodbye there").
* Replace all button disabled without replace text.

detail.test.js: "closes on ESC" stabilized — guard that detail is
actually open before dispatching Escape, poll (20×50ms) instead of
fixed sleep(150) for the .open class to be removed.

chore: bump version to 0.9.21

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

136 lines
4.9 KiB
JavaScript

import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
async function makeCard(title, body) {
return 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());
}
async function getCard(id) {
const all = await fetch(`api/cards?ids=${id}`).then(r => r.json());
return all.find(c => c.id == id);
}
function openBoardFR() {
// Ctrl+Shift+H opens the board find/replace overlay
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
}));
}
describe('Board: Find/Replace', () => {
afterEach(async () => {
document.getElementById('board-find-replace')?.remove();
await closeAll();
});
it('Ctrl+Shift+H opens the overlay', async () => {
openBoardFR();
// Wait for the overlay (it does an async api('api/cards') fetch)
for (let i = 0; i < 40; i++) {
if (document.getElementById('board-find-replace')) break;
await sleep(50);
}
expect(document.getElementById('board-find-replace')).toExist();
expect(document.getElementById('bfr-find')).toExist();
expect(document.getElementById('bfr-replace')).toExist();
});
it('typing in find shows matching cards', async () => {
const c1 = await makeCard('__bfr_match_a__', 'the quick brown fox');
const c2 = await makeCard('__bfr_match_b__', 'the quick red fox');
const c3 = await makeCard('__bfr_nomatch__', 'no match here');
await window._reloadAll?.();
await sleep(200);
openBoardFR();
for (let i = 0; i < 40; i++) {
if (document.getElementById('bfr-find')) break;
await sleep(50);
}
const findInp = document.getElementById('bfr-find');
if (!findInp) return;
findInp.value = 'quick';
findInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(100);
const status = document.getElementById('bfr-status');
// Should find 'quick' in at least 2 cards (c1 + c2)
expect(status.textContent).toContain('2');
// The list should mention both matching card titles
const list = document.getElementById('bfr-list');
expect(list.textContent).toContain('__bfr_match_a__');
expect(list.textContent).toContain('__bfr_match_b__');
// Non-matching card should NOT be in the list
expect(list.textContent.includes('__bfr_nomatch__')).toBe(false);
});
it('replace all updates card bodies after confirmation', async () => {
const c1 = await makeCard('__bfr_repl_a__', 'hello world hello');
const c2 = await makeCard('__bfr_repl_b__', 'hello there');
await window._reloadAll?.();
await sleep(200);
openBoardFR();
for (let i = 0; i < 40; i++) {
if (document.getElementById('bfr-find')) break;
await sleep(50);
}
const findInp = document.getElementById('bfr-find');
const replInp = document.getElementById('bfr-replace');
if (!findInp || !replInp) return;
findInp.value = 'hello';
findInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
replInp.value = 'goodbye';
replInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
const applyBtn = document.getElementById('bfr-apply');
expect(applyBtn.disabled).toBe(false);
click(applyBtn);
await sleep(200);
// The confirmation dialog should appear
const dialog = document.getElementById('dialog');
if (dialog?.classList.contains('open')) {
const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn);
await sleep(600);
}
// Verify card bodies were updated
const updated1 = await getCard(c1.id);
const updated2 = await getCard(c2.id);
expect(updated1.body).toBe('goodbye world goodbye');
expect(updated2.body).toBe('goodbye there');
});
it('replace all button disabled without replace text', async () => {
await makeCard('__bfr_dis__', 'some text');
await window._reloadAll?.();
await sleep(200);
openBoardFR();
for (let i = 0; i < 40; i++) {
if (document.getElementById('bfr-find')) break;
await sleep(50);
}
const findInp = document.getElementById('bfr-find');
if (!findInp) return;
findInp.value = 'some';
findInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
const applyBtn = document.getElementById('bfr-apply');
// Replace input is empty → button should be disabled
expect(applyBtn.disabled).toBe(true);
});
});