1// Tests of the side panel keys of static/js/ermite.js (ermite.rail, and j / k
2// inside the panel) on a tiny hand-rolled DOM. Run with
3// node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { readFileSync } from 'node:fs';
6import { test } from 'node:test';
7import { el, installDom } from './dom.mjs';
8
9// A page: <main>, and a rail with a hidden input, a closed-details command
10// (not laid out), then the controls a key can reach.
11const doc = installDom();
12const main = el('main', { id: 'main' });
13const hidden = el('input', { attrs: { type: 'hidden' } });
14const folded = el('a', { href: '/x', attrs: { href: '/x' } });
15folded.getClientRects = () => []; // inside a closed <details>
16const markRead = el('button', { cls: 'btn' });
17const ready = el('button', { cls: 'btn primary' });
18const close = el('button', { cls: 'btn danger' });
19const rail = el('aside', { cls: 'rail' }, hidden, folded, markRead, ready, close);
20document.body.append(main, rail);
21let active = document.body;
22Object.defineProperty(doc, 'activeElement', { get: () => active });
23for (const e of [main, markRead, ready, close, folded]) e.focus = () => { active = e; };
24rail.contains = (e) => { for (let n = e; n; n = n.parent) if (n === rail) return true; return false; };
25
26// Only the ermiteRail block of the file is evaluated: the rest needs a page.
27const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
28const start = src.indexOf(' window.ermiteRail = ');
29const end = src.indexOf(' })();', start) + ' })();'.length;
30new Function(src.slice(start, end))();
31const R = window.ermiteRail;
32const E = { rail: (into) => (into ? R.enter() : R.leave()), move: (d) => R.step(d) };
33
34test('l focuses the main action of the panel, j / k walk its shown controls, h goes back', () => {
35 active = main;
36 E.rail(true);
37 assert.equal(active, ready, 'the primary button first');
38 E.move(1);
39 assert.equal(active, close);
40 E.move(1);
41 assert.equal(active, close, 'stops at the last control');
42 E.move(-1);
43 E.move(-1);
44 assert.equal(active, markRead, 'the folded link is skipped');
45 E.move(-1);
46 assert.equal(active, markRead, 'stops at the first shown control');
47 E.rail(false);
48 assert.equal(active, main);
49});
50
51test('without a primary action, l takes the first shown control', () => {
52 ready.className = 'btn';
53 active = main;
54 E.rail(true);
55 assert.equal(active, markRead);
56 ready.className = 'btn primary';
57});
58
59test('h outside the panel leaves the focus alone; j / k outside it are not taken', () => {
60 active = main;
61 E.rail(false);
62 assert.equal(active, main);
63 assert.equal(R.step(1), false, 'the row cursor keeps j / k');
64});
65
66test('j / k cross section and recent-commit table boundaries in document order', () => {
67 const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } }));
68 for (const e of controls) e.focus = () => { active = e; };
69 rail.append(
70 el('section', {}, controls[0]),
71 el('section', {}, controls[1]),
72 el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))),
73 el('section', {}, controls[4]),
74 el('nav', {}, controls[5]),
75 );
76 active = close;
77 for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); }
78 R.step(1);
79 assert.equal(active, controls.at(-1));
80 for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); }
81 R.leave();
82 assert.equal(active, main);
83 assert.equal(R.step(1), false);
84});