detached 9494523

test(web): cover the finder and tree DOM glue

commit
9494523d0406b2b4752a3a4095a1e960747d726e
author
brice · · 2026-10-12 05:03 UTC
parent
84a2d29
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +505 -111
A internal/adapters/web/jstest/dom.mjs +191 -0
@@ -0,0 +1,191 @@
1+// A DOM just big enough for the glue of static/js/files.js: elements with
2+// attributes, dataset, children and the few selectors the script uses
3+// ("tag.class[attr][attr=v]:not([attr])", optionally after ":scope >" or
4+// "#id >"). Not a test file: the tests import it.
5+import assert from 'node:assert/strict';
6+
7+const kebab = (k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
8+
9+export class Text {
10+ constructor(data) {
11+ this.nodeType = 3;
12+ this.data = data;
13+ this.parent = null;
14+ }
15+ remove() {
16+ if (!this.parent) return;
17+ this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
18+ this.parent = null;
19+ }
20+ get textContent() { return this.data; }
21+}
22+
23+// A compound selector is checked on one element: tag, .class, #id, [attr],
24+// [attr=v], :not([attr]). `hidden` is a property of the element, as in a page.
25+function compound(sel) {
26+ const re = /([a-z][a-z0-9]*)|\.([\w-]+)|#([\w-]+)|:not\(\[([\w-]+)\]\)|\[([\w-]+)(?:=(?:"([^"]*)"|([^\]]*)))?\]/y;
27+ const want = { tag: null, cls: [], id: null, not: [], attrs: [] };
28+ while (re.lastIndex < sel.length) {
29+ const m = re.exec(sel);
30+ assert.ok(m, 'unsupported selector: ' + sel);
31+ if (m[1]) want.tag = m[1].toUpperCase();
32+ else if (m[2]) want.cls.push(m[2]);
33+ else if (m[3]) want.id = m[3];
34+ else if (m[4]) want.not.push(m[4]);
35+ else want.attrs.push([m[5], m[6] ?? m[7]]);
36+ }
37+ return (el) => {
38+ if (want.tag && el.tagName !== want.tag) return false;
39+ if (want.id && el.id !== want.id) return false;
40+ if (!want.cls.every((c) => el.className.split(/\s+/).includes(c))) return false;
41+ if (want.not.some((a) => el.hasAttribute(a))) return false;
42+ return want.attrs.every(([a, v]) => (v === undefined ? el.hasAttribute(a) : el.getAttribute(a) === v));
43+ };
44+}
45+
46+export class El {
47+ constructor(tag) {
48+ this.nodeType = 1;
49+ this.tagName = tag.toUpperCase();
50+ this.attrs = new Map();
51+ this.nodes = [];
52+ this.parent = null;
53+ this.hidden = false;
54+ this.className = '';
55+ this.id = '';
56+ this.value = '';
57+ this.href = '';
58+ this.tabIndex = 0;
59+ this.text = '';
60+ this.style = { props: {}, setProperty(k, v) { this.props[k] = v; } };
61+ this.dataset = new Proxy({}, {
62+ get: (_, k) => this.getAttribute(kebab(k)) ?? undefined,
63+ set: (_, k, v) => { this.setAttribute(kebab(k), v); return true; },
64+ has: (_, k) => this.hasAttribute(kebab(k)),
65+ });
66+ }
67+ setAttribute(k, v) { this.attrs.set(k, String(v)); }
68+ getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
69+ hasAttribute(k) { return k === 'hidden' ? this.hidden : this.attrs.has(k); }
70+ removeAttribute(k) { this.attrs.delete(k); }
71+
72+ get children() { return this.nodes.filter((n) => n.nodeType === 1); }
73+ get parentElement() { return this.parent; }
74+ get nextElementSibling() {
75+ if (!this.parent) return null;
76+ const sib = this.parent.children;
77+ return sib[sib.indexOf(this) + 1] || null;
78+ }
79+ get previousElementSibling() {
80+ if (!this.parent) return null;
81+ const sib = this.parent.children;
82+ return sib[sib.indexOf(this) - 1] || null;
83+ }
84+ get textContent() { return this.text + this.nodes.map((n) => n.textContent).join(''); }
85+ set textContent(v) {
86+ this.nodes.forEach((n) => { n.parent = null; });
87+ this.nodes = [];
88+ this.text = String(v);
89+ }
90+
91+ adopt(list) {
92+ return list.map((n) => {
93+ const c = typeof n === 'string' ? new Text(n) : n;
94+ c.remove();
95+ c.parent = this;
96+ return c;
97+ });
98+ }
99+ append(...list) { this.nodes.push(...this.adopt(list)); }
100+ replaceChildren(...list) {
101+ this.nodes.forEach((n) => { n.parent = null; });
102+ this.nodes = [];
103+ this.append(...list);
104+ }
105+ after(...list) {
106+ const p = this.parent;
107+ const added = p.adopt(list);
108+ p.nodes.splice(p.nodes.indexOf(this) + 1, 0, ...added);
109+ }
110+ remove() {
111+ if (!this.parent) return;
112+ this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
113+ this.parent = null;
114+ }
115+ closest(sel) {
116+ const ok = compound(sel);
117+ for (let e = this; e; e = e.parent) if (ok(e)) return e;
118+ return null;
119+ }
120+ matches(sel) { return compound(sel)(this); }
121+
122+ // querySelectorAll: ":scope > c" and "#id > c" look at the children of the
123+ // scope or of the element with that id; anything else at the descendants.
124+ querySelectorAll(sel) {
125+ const m = /^(:scope|#[\w-]+)\s*>\s*(.+)$/.exec(sel);
126+ const ok = compound(m ? m[2] : sel);
127+ if (m) {
128+ const scope = m[1] === ':scope' ? this : this.find((e) => e.id === m[1].slice(1));
129+ return scope ? scope.children.filter(ok) : [];
130+ }
131+ const out = [];
132+ const walk = (e) => e.children.forEach((c) => { if (ok(c)) out.push(c); walk(c); });
133+ walk(this);
134+ return out;
135+ }
136+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
137+ find(pred) {
138+ for (const c of this.children) {
139+ if (pred(c)) return c;
140+ const d = c.find(pred);
141+ if (d) return d;
142+ }
143+ return null;
144+ }
145+ scrollIntoView() {}
146+ click() { dom.clicked.push(this); }
147+}
148+
149+export const dom = { clicked: [], body: null };
150+
151+// installDom puts the globals files.js expects. parseHTML(text) answers the
152+// nodes that a template's innerHTML makes.
153+export function installDom({ parseHTML } = {}) {
154+ dom.body = new El('body');
155+ dom.clicked = [];
156+ const doc = {
157+ body: dom.body,
158+ createElement(tag) {
159+ const e = new El(tag);
160+ if (tag === 'template') {
161+ e.content = new El('#fragment');
162+ Object.defineProperty(e, 'innerHTML', { set(text) { e.content.replaceChildren(...parseHTML(text)); } });
163+ }
164+ return e;
165+ },
166+ createTextNode: (data) => new Text(data),
167+ getElementById: (id) => dom.body.find((e) => e.id === id),
168+ querySelectorAll: (sel) => dom.body.querySelectorAll(sel),
169+ querySelector: (sel) => dom.body.querySelector(sel),
170+ };
171+ globalThis.window = globalThis;
172+ globalThis.document = doc;
173+ globalThis.location = { href: '' };
174+ // A frame runs at once; a test still orders its steps around the awaits.
175+ globalThis.requestAnimationFrame = (f) => { f(); return 0; };
176+ globalThis.cancelAnimationFrame = () => {};
177+ return doc;
178+}
179+
180+// el makes an element: el('li', {id, cls, attrs}, ...children).
181+export function el(tag, { id = '', cls = '', attrs = {}, ...props } = {}, ...children) {
182+ const e = new El(tag);
183+ e.id = id;
184+ e.className = cls;
185+ Object.assign(e, props);
186+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
187+ e.append(...children);
188+ return e;
189+}
190+
191+export const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -0,0 +1,191 @@
1+// A DOM just big enough for the glue of static/js/files.js: elements with
2+// attributes, dataset, children and the few selectors the script uses
3+// ("tag.class[attr][attr=v]:not([attr])", optionally after ":scope >" or
4+// "#id >"). Not a test file: the tests import it.
5+import assert from 'node:assert/strict';
6+
7+const kebab = (k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
8+
9+export class Text {
10+ constructor(data) {
11+ this.nodeType = 3;
12+ this.data = data;
13+ this.parent = null;
14+ }
15+ remove() {
16+ if (!this.parent) return;
17+ this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
18+ this.parent = null;
19+ }
20+ get textContent() { return this.data; }
21+}
22+
23+// A compound selector is checked on one element: tag, .class, #id, [attr],
24+// [attr=v], :not([attr]). `hidden` is a property of the element, as in a page.
25+function compound(sel) {
26+ const re = /([a-z][a-z0-9]*)|\.([\w-]+)|#([\w-]+)|:not\(\[([\w-]+)\]\)|\[([\w-]+)(?:=(?:"([^"]*)"|([^\]]*)))?\]/y;
27+ const want = { tag: null, cls: [], id: null, not: [], attrs: [] };
28+ while (re.lastIndex < sel.length) {
29+ const m = re.exec(sel);
30+ assert.ok(m, 'unsupported selector: ' + sel);
31+ if (m[1]) want.tag = m[1].toUpperCase();
32+ else if (m[2]) want.cls.push(m[2]);
33+ else if (m[3]) want.id = m[3];
34+ else if (m[4]) want.not.push(m[4]);
35+ else want.attrs.push([m[5], m[6] ?? m[7]]);
36+ }
37+ return (el) => {
38+ if (want.tag && el.tagName !== want.tag) return false;
39+ if (want.id && el.id !== want.id) return false;
40+ if (!want.cls.every((c) => el.className.split(/\s+/).includes(c))) return false;
41+ if (want.not.some((a) => el.hasAttribute(a))) return false;
42+ return want.attrs.every(([a, v]) => (v === undefined ? el.hasAttribute(a) : el.getAttribute(a) === v));
43+ };
44+}
45+
46+export class El {
47+ constructor(tag) {
48+ this.nodeType = 1;
49+ this.tagName = tag.toUpperCase();
50+ this.attrs = new Map();
51+ this.nodes = [];
52+ this.parent = null;
53+ this.hidden = false;
54+ this.className = '';
55+ this.id = '';
56+ this.value = '';
57+ this.href = '';
58+ this.tabIndex = 0;
59+ this.text = '';
60+ this.style = { props: {}, setProperty(k, v) { this.props[k] = v; } };
61+ this.dataset = new Proxy({}, {
62+ get: (_, k) => this.getAttribute(kebab(k)) ?? undefined,
63+ set: (_, k, v) => { this.setAttribute(kebab(k), v); return true; },
64+ has: (_, k) => this.hasAttribute(kebab(k)),
65+ });
66+ }
67+ setAttribute(k, v) { this.attrs.set(k, String(v)); }
68+ getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
69+ hasAttribute(k) { return k === 'hidden' ? this.hidden : this.attrs.has(k); }
70+ removeAttribute(k) { this.attrs.delete(k); }
71+
72+ get children() { return this.nodes.filter((n) => n.nodeType === 1); }
73+ get parentElement() { return this.parent; }
74+ get nextElementSibling() {
75+ if (!this.parent) return null;
76+ const sib = this.parent.children;
77+ return sib[sib.indexOf(this) + 1] || null;
78+ }
79+ get previousElementSibling() {
80+ if (!this.parent) return null;
81+ const sib = this.parent.children;
82+ return sib[sib.indexOf(this) - 1] || null;
83+ }
84+ get textContent() { return this.text + this.nodes.map((n) => n.textContent).join(''); }
85+ set textContent(v) {
86+ this.nodes.forEach((n) => { n.parent = null; });
87+ this.nodes = [];
88+ this.text = String(v);
89+ }
90+
91+ adopt(list) {
92+ return list.map((n) => {
93+ const c = typeof n === 'string' ? new Text(n) : n;
94+ c.remove();
95+ c.parent = this;
96+ return c;
97+ });
98+ }
99+ append(...list) { this.nodes.push(...this.adopt(list)); }
100+ replaceChildren(...list) {
101+ this.nodes.forEach((n) => { n.parent = null; });
102+ this.nodes = [];
103+ this.append(...list);
104+ }
105+ after(...list) {
106+ const p = this.parent;
107+ const added = p.adopt(list);
108+ p.nodes.splice(p.nodes.indexOf(this) + 1, 0, ...added);
109+ }
110+ remove() {
111+ if (!this.parent) return;
112+ this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
113+ this.parent = null;
114+ }
115+ closest(sel) {
116+ const ok = compound(sel);
117+ for (let e = this; e; e = e.parent) if (ok(e)) return e;
118+ return null;
119+ }
120+ matches(sel) { return compound(sel)(this); }
121+
122+ // querySelectorAll: ":scope > c" and "#id > c" look at the children of the
123+ // scope or of the element with that id; anything else at the descendants.
124+ querySelectorAll(sel) {
125+ const m = /^(:scope|#[\w-]+)\s*>\s*(.+)$/.exec(sel);
126+ const ok = compound(m ? m[2] : sel);
127+ if (m) {
128+ const scope = m[1] === ':scope' ? this : this.find((e) => e.id === m[1].slice(1));
129+ return scope ? scope.children.filter(ok) : [];
130+ }
131+ const out = [];
132+ const walk = (e) => e.children.forEach((c) => { if (ok(c)) out.push(c); walk(c); });
133+ walk(this);
134+ return out;
135+ }
136+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
137+ find(pred) {
138+ for (const c of this.children) {
139+ if (pred(c)) return c;
140+ const d = c.find(pred);
141+ if (d) return d;
142+ }
143+ return null;
144+ }
145+ scrollIntoView() {}
146+ click() { dom.clicked.push(this); }
147+}
148+
149+export const dom = { clicked: [], body: null };
150+
151+// installDom puts the globals files.js expects. parseHTML(text) answers the
152+// nodes that a template's innerHTML makes.
153+export function installDom({ parseHTML } = {}) {
154+ dom.body = new El('body');
155+ dom.clicked = [];
156+ const doc = {
157+ body: dom.body,
158+ createElement(tag) {
159+ const e = new El(tag);
160+ if (tag === 'template') {
161+ e.content = new El('#fragment');
162+ Object.defineProperty(e, 'innerHTML', { set(text) { e.content.replaceChildren(...parseHTML(text)); } });
163+ }
164+ return e;
165+ },
166+ createTextNode: (data) => new Text(data),
167+ getElementById: (id) => dom.body.find((e) => e.id === id),
168+ querySelectorAll: (sel) => dom.body.querySelectorAll(sel),
169+ querySelector: (sel) => dom.body.querySelector(sel),
170+ };
171+ globalThis.window = globalThis;
172+ globalThis.document = doc;
173+ globalThis.location = { href: '' };
174+ // A frame runs at once; a test still orders its steps around the awaits.
175+ globalThis.requestAnimationFrame = (f) => { f(); return 0; };
176+ globalThis.cancelAnimationFrame = () => {};
177+ return doc;
178+}
179+
180+// el makes an element: el('li', {id, cls, attrs}, ...children).
181+export function el(tag, { id = '', cls = '', attrs = {}, ...props } = {}, ...children) {
182+ const e = new El(tag);
183+ e.id = id;
184+ e.className = cls;
185+ Object.assign(e, props);
186+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
187+ e.append(...children);
188+ return e;
189+}
190+
191+export const settle = () => new Promise((r) => setTimeout(r, 0));
M internal/adapters/web/jstest/files.test.mjs +27 -34
@@ -25,28 +25,13 @@ const idx = F.index(REPO);
2525 const top = (q, k = 50, prev = null) => F.search(idx, q, k, prev);
2626 const names = (r) => r.hits.map((h) => REPO[h.i]);
2727
28-test('index: ASCII-only folding keeps positions', () => {
29- const x = F.index(['İndex/Straße.go', 'ABC/Def.TXT']);
30- assert.equal(x.lower[1], 'abc/def.txt');
31- assert.equal(x.lower[0].length, x.paths[0].length);
32- assert.equal(x.lower[0][0], 'İ', 'non-ASCII is not folded');
33- assert.equal(x.base[0], 'İndex/'.length);
34- assert.equal(x.base[1], 4);
35-});
36-
37-test('index: mask bits', () => {
38- const x = F.index(['a', 'z', '5', '/', '.', '-', '_', 'é', 'A']);
39- assert.deepEqual(Array.from(x.mask), [1, 1 << 25, 1 << 26, 1 << 27, 1 << 28, 1 << 29, 1 << 29, 1 << 30, 1]);
40-});
41-
4228 test('search: rejects what is not a subsequence', () => {
4329 assert.deepEqual(names(top('zzz')), []);
4430 assert.deepEqual(names(top('gnirt')), [], 'order matters');
4531 assert.equal(top('zzz').all.length, 0);
4632 assert.deepEqual(names(top('')), []);
4733 assert.deepEqual(names(top(' ')), []);
48- // The mask rejects a character no path has.
49- assert.equal(top('q').all.length, 0);
34+ assert.equal(top('q').all.length, 0, 'a character no path has');
5035 });
5136
5237 test('search: ranking', () => {
@@ -91,14 +76,6 @@ test('search: several terms', () => {
9176 assert.deepEqual(new Set(names(top('tree web'))), new Set(names(top('web tree'))));
9277 });
9378
94-test('search: case is folded for ASCII only', () => {
95- assert.ok(names(top('README')).includes('README.md'));
96- assert.ok(names(top('readme')).includes('README.md'));
97- const x = F.index(['İndex/Straße.go']);
98- assert.equal(F.search(x, 'index').hits.length, 0, 'İ is not an ASCII capital I');
99- assert.equal(F.search(x, 'ndex').hits.length, 1);
100-});
101-
10279 test('search: top k, ties by length then path', () => {
10380 const ps = [];
10481 for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt');
@@ -161,16 +138,32 @@ test('positions', () => {
161138 assert.equal(both.map((i) => z.paths[0][i]).join(''), 'webtree');
162139 });
163140
164-test('positions on a non-ASCII path index the original string', () => {
165- const x = F.index(['İndex/Straße.go']);
166- const pos = F.positions(x, 0, 'strasse');
167- assert.deepEqual(pos, [], 'ß is not ss: nothing is claimed');
168- const q = F.positions(x, 0, 'stra');
169- const s = x.paths[0];
170- assert.equal(q.map((i) => s[i]).join(''), 'Stra');
171- const r = F.positions(x, 0, 'ndex');
172- assert.equal(r.map((i) => s[i]).join(''), 'ndex');
173- assert.equal(r[0], 1);
141+// Case is folded for A-Z only, and a position is an index of the original
142+// string; both are seen through search and positions.
143+test('non-ASCII paths: ASCII-only folding, positions index the original', () => {
144+ const cases = [
145+ // path, query, the characters the query marks (null: no match)
146+ ['İndex/Straße.go', 'index', null], // İ is not an ASCII capital I
147+ ['İndex/Straße.go', 'ndex', 'ndex'],
148+ ['İndex/Straße.go', 'stra', 'Stra'],
149+ ['İndex/Straße.go', 'strasse', null], // ß is not ss
150+ ['ABC/Def.TXT', 'abc/def.txt', 'ABC/Def.TXT'],
151+ ['README.md', 'readme', 'README'],
152+ ['README.md', 'README', 'README'],
153+ ];
154+ for (const [path, q, want] of cases) {
155+ const x = F.index([path]);
156+ const r = F.search(x, q);
157+ const pos = F.positions(x, 0, q);
158+ if (want === null) {
159+ assert.equal(r.hits.length, 0, `${q} in ${path}`);
160+ assert.deepEqual(pos, [], `${q} in ${path}: nothing is claimed`);
161+ } else {
162+ assert.equal(r.hits.length, 1, `${q} in ${path}`);
163+ assert.equal(pos.map((i) => path[i]).join(''), want, `${q} in ${path}`);
164+ }
165+ }
166+ assert.equal(F.positions(F.index(['İndex/Straße.go']), 0, 'ndex')[0], 1, 'the İ before it counts as one');
174167 });
175168
176169 test('positions of a long path (greedy)', () => {
@@ -25,28 +25,13 @@ const idx = F.index(REPO);
25 const top = (q, k = 50, prev = null) => F.search(idx, q, k, prev);25 const top = (q, k = 50, prev = null) => F.search(idx, q, k, prev);
26 const names = (r) => r.hits.map((h) => REPO[h.i]);26 const names = (r) => r.hits.map((h) => REPO[h.i]);
27 27
28-test('index: ASCII-only folding keeps positions', () => {
29- const x = F.index(['İndex/Straße.go', 'ABC/Def.TXT']);
30- assert.equal(x.lower[1], 'abc/def.txt');
31- assert.equal(x.lower[0].length, x.paths[0].length);
32- assert.equal(x.lower[0][0], 'İ', 'non-ASCII is not folded');
33- assert.equal(x.base[0], 'İndex/'.length);
34- assert.equal(x.base[1], 4);
35-});
36-
37-test('index: mask bits', () => {
38- const x = F.index(['a', 'z', '5', '/', '.', '-', '_', 'é', 'A']);
39- assert.deepEqual(Array.from(x.mask), [1, 1 << 25, 1 << 26, 1 << 27, 1 << 28, 1 << 29, 1 << 29, 1 << 30, 1]);
40-});
41-
42 test('search: rejects what is not a subsequence', () => {28 test('search: rejects what is not a subsequence', () => {
43 assert.deepEqual(names(top('zzz')), []);29 assert.deepEqual(names(top('zzz')), []);
44 assert.deepEqual(names(top('gnirt')), [], 'order matters');30 assert.deepEqual(names(top('gnirt')), [], 'order matters');
45 assert.equal(top('zzz').all.length, 0);31 assert.equal(top('zzz').all.length, 0);
46 assert.deepEqual(names(top('')), []);32 assert.deepEqual(names(top('')), []);
47 assert.deepEqual(names(top(' ')), []);33 assert.deepEqual(names(top(' ')), []);
48- // The mask rejects a character no path has.34+ assert.equal(top('q').all.length, 0, 'a character no path has');
49- assert.equal(top('q').all.length, 0);
50 });35 });
51 36
52 test('search: ranking', () => {37 test('search: ranking', () => {
@@ -91,14 +76,6 @@ test('search: several terms', () => {
91 assert.deepEqual(new Set(names(top('tree web'))), new Set(names(top('web tree'))));76 assert.deepEqual(new Set(names(top('tree web'))), new Set(names(top('web tree'))));
92 });77 });
93 78
94-test('search: case is folded for ASCII only', () => {
95- assert.ok(names(top('README')).includes('README.md'));
96- assert.ok(names(top('readme')).includes('README.md'));
97- const x = F.index(['İndex/Straße.go']);
98- assert.equal(F.search(x, 'index').hits.length, 0, 'İ is not an ASCII capital I');
99- assert.equal(F.search(x, 'ndex').hits.length, 1);
100-});
101-
102 test('search: top k, ties by length then path', () => {79 test('search: top k, ties by length then path', () => {
103 const ps = [];80 const ps = [];
104 for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt');81 for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt');
@@ -161,16 +138,32 @@ test('positions', () => {
161 assert.equal(both.map((i) => z.paths[0][i]).join(''), 'webtree');138 assert.equal(both.map((i) => z.paths[0][i]).join(''), 'webtree');
162 });139 });
163 140
164-test('positions on a non-ASCII path index the original string', () => {141+// Case is folded for A-Z only, and a position is an index of the original
165- const x = F.index(['İndex/Straße.go']);142+// string; both are seen through search and positions.
166- const pos = F.positions(x, 0, 'strasse');143+test('non-ASCII paths: ASCII-only folding, positions index the original', () => {
167- assert.deepEqual(pos, [], 'ß is not ss: nothing is claimed');144+ const cases = [
168- const q = F.positions(x, 0, 'stra');145+ // path, query, the characters the query marks (null: no match)
169- const s = x.paths[0];146+ ['İndex/Straße.go', 'index', null], // İ is not an ASCII capital I
170- assert.equal(q.map((i) => s[i]).join(''), 'Stra');147+ ['İndex/Straße.go', 'ndex', 'ndex'],
171- const r = F.positions(x, 0, 'ndex');148+ ['İndex/Straße.go', 'stra', 'Stra'],
172- assert.equal(r.map((i) => s[i]).join(''), 'ndex');149+ ['İndex/Straße.go', 'strasse', null], // ß is not ss
173- assert.equal(r[0], 1);150+ ['ABC/Def.TXT', 'abc/def.txt', 'ABC/Def.TXT'],
151+ ['README.md', 'readme', 'README'],
152+ ['README.md', 'README', 'README'],
153+ ];
154+ for (const [path, q, want] of cases) {
155+ const x = F.index([path]);
156+ const r = F.search(x, q);
157+ const pos = F.positions(x, 0, q);
158+ if (want === null) {
159+ assert.equal(r.hits.length, 0, `${q} in ${path}`);
160+ assert.deepEqual(pos, [], `${q} in ${path}: nothing is claimed`);
161+ } else {
162+ assert.equal(r.hits.length, 1, `${q} in ${path}`);
163+ assert.equal(pos.map((i) => path[i]).join(''), want, `${q} in ${path}`);
164+ }
165+ }
166+ assert.equal(F.positions(F.index(['İndex/Straße.go']), 0, 'ndex')[0], 1, 'the İ before it counts as one');
174 });167 });
175 168
176 test('positions of a long path (greedy)', () => {169 test('positions of a long path (greedy)', () => {
A internal/adapters/web/jstest/finder.test.mjs +218 -0
@@ -0,0 +1,218 @@
1+// Tests of the DOM glue of the file finder and of the ":" palette
2+// (static/js/files.js) on the small DOM of dom.mjs. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+import { dom, el, installDom, settle } from './dom.mjs';
7+
8+installDom();
9+await import('../static/js/files.js');
10+const F = window.ermiteFinder;
11+
12+const PATHS = [
13+ 'README.md',
14+ 'cmd/ermite/main.go',
15+ 'internal/adapters/web/tree.go',
16+ 'internal/adapters/web/tree.templ',
17+ 'docs/web/tree-view.md',
18+ 'src/treehouse.txt',
19+];
20+for (let i = 0; i < 1194; i++) PATHS.push('gen/f' + i + '.dat'); // 1,200 in all
21+
22+// The path list answers when the test says so (once: the finder keeps it).
23+let release;
24+const gate = new Promise((r) => { release = r; });
25+let fetches = 0;
26+globalThis.fetch = async () => {
27+ fetches++;
28+ await gate;
29+ return { ok: true, json: async () => ({ paths: PATHS, truncated: true }) };
30+};
31+
32+// page builds the parts of the Files view and of the palette that files.js
33+// reaches, around a query field and a tree of three rows.
34+function page() {
35+ const rows = ['r1', 'r2', 'r3'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
36+ rows[1].setAttribute('data-cursor', '');
37+ const q = el('input', { id: 'finder-q' });
38+ const cmdQ = el('input', { id: 'cmd-q' });
39+ const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
40+ const finderList = el('ul', { id: 'finder-list' });
41+ const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' }));
42+ const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList);
43+ const treeView = el('div', { id: 'tree-view' }, treeRows);
44+ dom.body.replaceChildren(q, cmdQ, treeView, finder, cmdList);
45+ dom.body.setAttribute('data-paths', '/paths.json');
46+ dom.body.setAttribute('data-blob', '/blob/main');
47+ dom.clicked = [];
48+ return { rows, q, cmdQ, treeRows, finderList, cmdList, finder, treeView };
49+}
50+
51+const key = (k, extra = {}) => {
52+ const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, ...extra };
53+ return e;
54+};
55+const status = () => document.getElementById('finder-status').textContent;
56+const hrefs = (list) => list.children.map((li) => li.querySelector('a').href);
57+
58+test('Enter before the results opens the top hit once they come', async () => {
59+ const p = page();
60+ p.q.value = 'tree';
61+ F.input(p.q);
62+ assert.equal(p.finder.hidden, false, 'the results replace the tree at once');
63+ F.key(p.q, key('Enter'));
64+ assert.equal(dom.clicked.length, 0, 'nothing to open yet');
65+ release();
66+ await settle();
67+ assert.equal(p.finderList.children.length > 0, true);
68+ assert.equal(dom.clicked.length, 1);
69+ assert.equal(dom.clicked[0].href, hrefs(p.finderList)[0]);
70+ assert.match(dom.clicked[0].href, /^\/blob\/main\/.*tree/);
71+ assert.equal(fetches, 1);
72+});
73+
74+test('the status counts the files and names the real size of a truncated list', async () => {
75+ const p = page();
76+ p.q.value = 'tree';
77+ F.input(p.q);
78+ await settle();
79+ assert.equal(status(), '4 files match · only the first 1,200 files are searched');
80+ p.q.value = 'zzzz';
81+ F.input(p.q);
82+ await settle();
83+ assert.match(status(), /^no file matches · /);
84+});
85+
86+test('an Enter is dropped when the field changed since', async () => {
87+ const p = page();
88+ p.q.value = 'tree';
89+ F.input(p.q);
90+ F.key(p.q, key('Enter')); // pending on "tree"
91+ p.q.value = 'tre';
92+ F.input(p.q);
93+ await settle();
94+ assert.equal(p.finderList.children.length > 0, true, 'the results of "tre" are shown');
95+ assert.equal(dom.clicked.length, 0);
96+});
97+
98+test('Enter on current results opens the cursor row, whichever way the cursor moved', async () => {
99+ const p = page();
100+ p.q.value = 'tree';
101+ F.input(p.q);
102+ await settle();
103+ const items = p.finderList.children;
104+ assert.equal(items[0].getAttribute('aria-selected'), 'true');
105+ assert.equal(p.q.getAttribute('aria-activedescendant'), items[0].id);
106+ // j/k (keys.templ) move data-cursor while the field is blurred.
107+ items[0].removeAttribute('data-cursor');
108+ items[2].setAttribute('data-cursor', '');
109+ const e = key('Enter');
110+ F.key(p.q, e);
111+ assert.equal(e.prevented, true);
112+ assert.equal(items[2].getAttribute('aria-selected'), 'true');
113+ assert.equal(items[0].getAttribute('aria-selected'), 'false');
114+ assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);
115+ assert.equal(dom.clicked[0].href, items[2].querySelector('a').href);
116+ // The arrows move from there.
117+ F.key(p.q, key('ArrowUp'));
118+ assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);
119+ assert.ok(items[1].hasAttribute('data-cursor') && !items[2].hasAttribute('data-cursor'));
120+});
121+
122+test('an input method composing does not drive the finder', async () => {
123+ const p = page();
124+ p.q.value = 'tree';
125+ F.input(p.q);
126+ await settle();
127+ for (const k of ['Enter', 'Escape', 'ArrowDown']) {
128+ const e = key(k, { isComposing: true });
129+ F.key(p.q, e);
130+ assert.equal(e.prevented, false, k);
131+ }
132+ assert.equal(p.q.value, 'tree');
133+ assert.equal(dom.clicked.length, 0);
134+});
135+
136+test('Escape clears the field and puts the cursor back on the tree', async () => {
137+ const cases = [
138+ { name: 'the row that had it', hide: false, want: 'r2' },
139+ { name: 'the first visible row when it is hidden', hide: true, want: 'r1' },
140+ ];
141+ for (const c of cases) {
142+ const p = page();
143+ p.q.value = 'tree';
144+ F.input(p.q);
145+ await settle();
146+ assert.equal(p.treeView.hidden, true);
147+ assert.ok(!p.rows[1].hasAttribute('data-cursor'), 'the tree gives up its cursor to the results');
148+ assert.ok(p.finderList.hasAttribute('data-rows'));
149+ if (c.hide) p.rows[1].hidden = true;
150+ F.key(p.q, key('Escape'));
151+ assert.equal(p.q.value, '', c.name);
152+ assert.equal(p.finder.hidden, true);
153+ assert.equal(p.treeView.hidden, false);
154+ assert.equal(p.finderList.children.length, 0);
155+ const got = p.rows.filter((r) => r.hasAttribute('data-cursor')).map((r) => r.getAttribute('data-nm'));
156+ assert.deepEqual(got, [c.want], c.name);
157+ assert.ok(p.treeRows.hasAttribute('data-rows') && !p.finderList.hasAttribute('data-rows'));
158+ }
159+});
160+
161+test('label marks a surrogate pair whole', () => {
162+ for (const pos of [[2], [3], [2, 3]]) {
163+ const lbl = F.label('a/😀b', pos);
164+ const marks = lbl.querySelectorAll('mark');
165+ assert.deepEqual(marks.map((m) => m.textContent), ['😀'], JSON.stringify(pos));
166+ assert.equal(lbl.textContent, 'a/😀b');
167+ }
168+ const lbl = F.label('a/😀b', [4]);
169+ assert.deepEqual(lbl.querySelectorAll('mark').map((m) => m.textContent), ['b']);
170+ assert.equal(F.label('x\u202ey', [0]).querySelectorAll('mark').length, 0, 'no highlight on a marked path');
171+ assert.equal(F.label('x\u202ey', [0]).textContent, 'x<U+202E>y');
172+});
173+
174+test('the palette lists the file hits under its entries', async () => {
175+ const p = page();
176+ p.cmdQ.value = 'tree';
177+ F.palette(p.cmdQ);
178+ await settle();
179+ const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');
180+ assert.equal(hits.length, 4);
181+ assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');
182+ assert.deepEqual(hits.map((li) => li.querySelector('a').href).sort(), [
183+ '/blob/main/docs/web/tree-view.md',
184+ '/blob/main/internal/adapters/web/tree.go',
185+ '/blob/main/internal/adapters/web/tree.templ',
186+ '/blob/main/src/treehouse.txt',
187+ ]);
188+ assert.equal(hits[0].querySelector('a').tabIndex, -1);
189+ assert.equal(hits[0].querySelector('.k').textContent, 'file');
190+ assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
191+
192+ // A longer query narrows the hits; an emptied field removes them.
193+ p.cmdQ.value = 'treeh';
194+ F.palette(p.cmdQ);
195+ await settle();
196+ assert.deepEqual(hrefs({ children: p.cmdList.children.filter((li) => li.className === 'finder-hit') }), ['/blob/main/src/treehouse.txt']);
197+ p.cmdQ.value = '';
198+ F.palette(p.cmdQ);
199+ assert.equal(p.cmdList.children.length, 1);
200+});
201+
202+test('the palette drops the results of a query that is stale', async () => {
203+ const p = page();
204+ p.cmdQ.value = 'tree';
205+ F.palette(p.cmdQ);
206+ p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
207+ F.palette(p.cmdQ);
208+ await settle();
209+ const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href);
210+ assert.deepEqual(got, ['/blob/main/README.md']);
211+
212+ // A field changed without a new call: the pending search is dropped.
213+ p.cmdQ.value = 'main';
214+ F.palette(p.cmdQ);
215+ p.cmdQ.value = 'zzzz';
216+ await settle();
217+ assert.deepEqual(p.cmdList.children.filter((li) => li.className === 'finder-hit'), []);
218+});
@@ -0,0 +1,218 @@
1+// Tests of the DOM glue of the file finder and of the ":" palette
2+// (static/js/files.js) on the small DOM of dom.mjs. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+import { dom, el, installDom, settle } from './dom.mjs';
7+
8+installDom();
9+await import('../static/js/files.js');
10+const F = window.ermiteFinder;
11+
12+const PATHS = [
13+ 'README.md',
14+ 'cmd/ermite/main.go',
15+ 'internal/adapters/web/tree.go',
16+ 'internal/adapters/web/tree.templ',
17+ 'docs/web/tree-view.md',
18+ 'src/treehouse.txt',
19+];
20+for (let i = 0; i < 1194; i++) PATHS.push('gen/f' + i + '.dat'); // 1,200 in all
21+
22+// The path list answers when the test says so (once: the finder keeps it).
23+let release;
24+const gate = new Promise((r) => { release = r; });
25+let fetches = 0;
26+globalThis.fetch = async () => {
27+ fetches++;
28+ await gate;
29+ return { ok: true, json: async () => ({ paths: PATHS, truncated: true }) };
30+};
31+
32+// page builds the parts of the Files view and of the palette that files.js
33+// reaches, around a query field and a tree of three rows.
34+function page() {
35+ const rows = ['r1', 'r2', 'r3'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
36+ rows[1].setAttribute('data-cursor', '');
37+ const q = el('input', { id: 'finder-q' });
38+ const cmdQ = el('input', { id: 'cmd-q' });
39+ const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
40+ const finderList = el('ul', { id: 'finder-list' });
41+ const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' }));
42+ const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList);
43+ const treeView = el('div', { id: 'tree-view' }, treeRows);
44+ dom.body.replaceChildren(q, cmdQ, treeView, finder, cmdList);
45+ dom.body.setAttribute('data-paths', '/paths.json');
46+ dom.body.setAttribute('data-blob', '/blob/main');
47+ dom.clicked = [];
48+ return { rows, q, cmdQ, treeRows, finderList, cmdList, finder, treeView };
49+}
50+
51+const key = (k, extra = {}) => {
52+ const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, ...extra };
53+ return e;
54+};
55+const status = () => document.getElementById('finder-status').textContent;
56+const hrefs = (list) => list.children.map((li) => li.querySelector('a').href);
57+
58+test('Enter before the results opens the top hit once they come', async () => {
59+ const p = page();
60+ p.q.value = 'tree';
61+ F.input(p.q);
62+ assert.equal(p.finder.hidden, false, 'the results replace the tree at once');
63+ F.key(p.q, key('Enter'));
64+ assert.equal(dom.clicked.length, 0, 'nothing to open yet');
65+ release();
66+ await settle();
67+ assert.equal(p.finderList.children.length > 0, true);
68+ assert.equal(dom.clicked.length, 1);
69+ assert.equal(dom.clicked[0].href, hrefs(p.finderList)[0]);
70+ assert.match(dom.clicked[0].href, /^\/blob\/main\/.*tree/);
71+ assert.equal(fetches, 1);
72+});
73+
74+test('the status counts the files and names the real size of a truncated list', async () => {
75+ const p = page();
76+ p.q.value = 'tree';
77+ F.input(p.q);
78+ await settle();
79+ assert.equal(status(), '4 files match · only the first 1,200 files are searched');
80+ p.q.value = 'zzzz';
81+ F.input(p.q);
82+ await settle();
83+ assert.match(status(), /^no file matches · /);
84+});
85+
86+test('an Enter is dropped when the field changed since', async () => {
87+ const p = page();
88+ p.q.value = 'tree';
89+ F.input(p.q);
90+ F.key(p.q, key('Enter')); // pending on "tree"
91+ p.q.value = 'tre';
92+ F.input(p.q);
93+ await settle();
94+ assert.equal(p.finderList.children.length > 0, true, 'the results of "tre" are shown');
95+ assert.equal(dom.clicked.length, 0);
96+});
97+
98+test('Enter on current results opens the cursor row, whichever way the cursor moved', async () => {
99+ const p = page();
100+ p.q.value = 'tree';
101+ F.input(p.q);
102+ await settle();
103+ const items = p.finderList.children;
104+ assert.equal(items[0].getAttribute('aria-selected'), 'true');
105+ assert.equal(p.q.getAttribute('aria-activedescendant'), items[0].id);
106+ // j/k (keys.templ) move data-cursor while the field is blurred.
107+ items[0].removeAttribute('data-cursor');
108+ items[2].setAttribute('data-cursor', '');
109+ const e = key('Enter');
110+ F.key(p.q, e);
111+ assert.equal(e.prevented, true);
112+ assert.equal(items[2].getAttribute('aria-selected'), 'true');
113+ assert.equal(items[0].getAttribute('aria-selected'), 'false');
114+ assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);
115+ assert.equal(dom.clicked[0].href, items[2].querySelector('a').href);
116+ // The arrows move from there.
117+ F.key(p.q, key('ArrowUp'));
118+ assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);
119+ assert.ok(items[1].hasAttribute('data-cursor') && !items[2].hasAttribute('data-cursor'));
120+});
121+
122+test('an input method composing does not drive the finder', async () => {
123+ const p = page();
124+ p.q.value = 'tree';
125+ F.input(p.q);
126+ await settle();
127+ for (const k of ['Enter', 'Escape', 'ArrowDown']) {
128+ const e = key(k, { isComposing: true });
129+ F.key(p.q, e);
130+ assert.equal(e.prevented, false, k);
131+ }
132+ assert.equal(p.q.value, 'tree');
133+ assert.equal(dom.clicked.length, 0);
134+});
135+
136+test('Escape clears the field and puts the cursor back on the tree', async () => {
137+ const cases = [
138+ { name: 'the row that had it', hide: false, want: 'r2' },
139+ { name: 'the first visible row when it is hidden', hide: true, want: 'r1' },
140+ ];
141+ for (const c of cases) {
142+ const p = page();
143+ p.q.value = 'tree';
144+ F.input(p.q);
145+ await settle();
146+ assert.equal(p.treeView.hidden, true);
147+ assert.ok(!p.rows[1].hasAttribute('data-cursor'), 'the tree gives up its cursor to the results');
148+ assert.ok(p.finderList.hasAttribute('data-rows'));
149+ if (c.hide) p.rows[1].hidden = true;
150+ F.key(p.q, key('Escape'));
151+ assert.equal(p.q.value, '', c.name);
152+ assert.equal(p.finder.hidden, true);
153+ assert.equal(p.treeView.hidden, false);
154+ assert.equal(p.finderList.children.length, 0);
155+ const got = p.rows.filter((r) => r.hasAttribute('data-cursor')).map((r) => r.getAttribute('data-nm'));
156+ assert.deepEqual(got, [c.want], c.name);
157+ assert.ok(p.treeRows.hasAttribute('data-rows') && !p.finderList.hasAttribute('data-rows'));
158+ }
159+});
160+
161+test('label marks a surrogate pair whole', () => {
162+ for (const pos of [[2], [3], [2, 3]]) {
163+ const lbl = F.label('a/😀b', pos);
164+ const marks = lbl.querySelectorAll('mark');
165+ assert.deepEqual(marks.map((m) => m.textContent), ['😀'], JSON.stringify(pos));
166+ assert.equal(lbl.textContent, 'a/😀b');
167+ }
168+ const lbl = F.label('a/😀b', [4]);
169+ assert.deepEqual(lbl.querySelectorAll('mark').map((m) => m.textContent), ['b']);
170+ assert.equal(F.label('x\u202ey', [0]).querySelectorAll('mark').length, 0, 'no highlight on a marked path');
171+ assert.equal(F.label('x\u202ey', [0]).textContent, 'x<U+202E>y');
172+});
173+
174+test('the palette lists the file hits under its entries', async () => {
175+ const p = page();
176+ p.cmdQ.value = 'tree';
177+ F.palette(p.cmdQ);
178+ await settle();
179+ const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');
180+ assert.equal(hits.length, 4);
181+ assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');
182+ assert.deepEqual(hits.map((li) => li.querySelector('a').href).sort(), [
183+ '/blob/main/docs/web/tree-view.md',
184+ '/blob/main/internal/adapters/web/tree.go',
185+ '/blob/main/internal/adapters/web/tree.templ',
186+ '/blob/main/src/treehouse.txt',
187+ ]);
188+ assert.equal(hits[0].querySelector('a').tabIndex, -1);
189+ assert.equal(hits[0].querySelector('.k').textContent, 'file');
190+ assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
191+
192+ // A longer query narrows the hits; an emptied field removes them.
193+ p.cmdQ.value = 'treeh';
194+ F.palette(p.cmdQ);
195+ await settle();
196+ assert.deepEqual(hrefs({ children: p.cmdList.children.filter((li) => li.className === 'finder-hit') }), ['/blob/main/src/treehouse.txt']);
197+ p.cmdQ.value = '';
198+ F.palette(p.cmdQ);
199+ assert.equal(p.cmdList.children.length, 1);
200+});
201+
202+test('the palette drops the results of a query that is stale', async () => {
203+ const p = page();
204+ p.cmdQ.value = 'tree';
205+ F.palette(p.cmdQ);
206+ p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
207+ F.palette(p.cmdQ);
208+ await settle();
209+ const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href);
210+ assert.deepEqual(got, ['/blob/main/README.md']);
211+
212+ // A field changed without a new call: the pending search is dropped.
213+ p.cmdQ.value = 'main';
214+ F.palette(p.cmdQ);
215+ p.cmdQ.value = 'zzzz';
216+ await settle();
217+ assert.deepEqual(p.cmdList.children.filter((li) => li.className === 'finder-hit'), []);
218+});
M internal/adapters/web/jstest/tree.test.mjs +69 -77
@@ -3,86 +3,23 @@
33 // node --test 'internal/adapters/web/jstest/*.test.mjs'
44 import assert from 'node:assert/strict';
55 import { test } from 'node:test';
6+import { El, el, installDom } from './dom.mjs';
67
7-// ---- a DOM just big enough for ermiteTree ----------------------------------
8-
9-class El {
10- constructor(tag) {
11- this.tagName = tag.toUpperCase();
12- this.attrs = new Map();
13- this.dataset = {};
14- this.children = [];
15- this.parent = null;
16- this.hidden = false;
17- this.style = { setProperty() {} };
18- }
19- setAttribute(k, v) { this.attrs.set(k, String(v)); }
20- getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
21- hasAttribute(k) { return this.attrs.has(k); }
22- removeAttribute(k) { this.attrs.delete(k); }
23- append(c) { c.parent = this; this.children.push(c); }
24- get nextElementSibling() {
25- if (!this.parent) return null;
26- return this.parent.children[this.parent.children.indexOf(this) + 1] || null;
27- }
28- after(...rows) {
29- const sib = this.parent.children;
30- for (const r of rows) r.parent = this.parent;
31- sib.splice(sib.indexOf(this) + 1, 0, ...rows);
32- }
33- closest(sel) {
34- for (let e = this; e; e = e.parent) if (e.matches(sel)) return e;
35- return null;
36- }
37- matches(sel) {
38- if (sel === 'tr') return this.tagName === 'TR';
39- if (sel === 'a[data-expand]') return this.tagName === 'A' && 'expand' in this.dataset;
40- return false;
41- }
42- querySelector(sel) {
43- for (const c of this.children) {
44- if (c.matches(sel) || (sel === '.c-kind' && c.cls === 'c-kind')) return c;
45- const d = c.querySelector(sel);
46- if (d) return d;
47- }
48- return null;
49- }
50-}
51-
52-// row makes a table row; a name ending in "/" is a directory with a link.
8+// A row is a name: a trailing "/" makes a directory with a link, "!x" a div
9+// that is not a row, "more" the tree-more row of a truncated directory.
5310 function row(name, depth) {
54- const tr = new El('tr');
55- tr.dataset.depth = String(depth);
11+ const tr = el('tr', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
5612 tr.name = name;
57- const kind = new El('span');
58- kind.cls = 'c-kind';
59- kind.textContent = '▸';
60- tr.append(kind);
13+ tr.append(el('td', { cls: 'c-kind', text: '▸' }));
6114 if (name.endsWith('/')) {
62- const a = new El('a');
63- a.dataset.expand = '/rows/' + name;
64- a.href = '/tree/' + name;
65- a.setAttribute('aria-expanded', 'false');
66- tr.append(a);
15+ tr.append(el('a', { href: '/tree/' + name, attrs: { 'data-expand': '/rows/' + name, 'aria-expanded': 'false' } }));
6716 }
6817 return tr;
6918 }
7019
71-// A fragment is "x,y/,!z": ! makes a div, not a row.
72-globalThis.document = {
73- createElement(tag) {
74- assert.equal(tag, 'template');
75- const t = { content: { children: [] } };
76- Object.defineProperty(t, 'innerHTML', {
77- set(text) {
78- t.content.children = text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0)));
79- },
80- });
81- return t;
82- },
83-};
84-globalThis.location = { href: '' };
85-globalThis.window = globalThis;
20+installDom({
21+ parseHTML: (text) => text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0))),
22+});
8623 await import('../static/js/files.js');
8724 const T = window.ermiteTree;
8825
@@ -96,16 +33,18 @@ const answer = async (url, text, ok) => {
9633 await new Promise((r) => setTimeout(r, 0));
9734 };
9835
99-const click = (tr) => {
36+// click sends a click on the link of tr; it answers whether the page's own
37+// navigation was kept (no preventDefault).
38+const click = (tr, mods = {}) => {
10039 const a = tr.querySelector('a[data-expand]');
101- T.click({ target: a, button: 0, preventDefault() {} });
40+ let prevented = false;
41+ T.click({ target: a, button: 0, preventDefault() { prevented = true; }, ...mods });
42+ return !prevented;
10243 };
10344 const kindOf = (tr) => tr.querySelector('.c-kind').textContent;
10445
10546 function table(...names) {
106- const body = new El('tbody');
107- for (const n of names) body.append(row(n, 0));
108- return body;
47+ return el('tbody', {}, ...names.map((n) => row(n, 0)));
10948 }
11049 const byName = (body, n) => body.children.find((r) => r.name === n);
11150 const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
@@ -183,3 +122,56 @@ test('TS-006: only rows are kept; no row falls back to navigating', async () =>
183122 assert.equal(globalThis.location.href, '/tree/Q/');
184123 assert.deepEqual(other.children.map((r) => r.name), ['Q/']);
185124 });
125+
126+test('a modified or non-left click keeps the link navigation', () => {
127+ pending.clear();
128+ const cases = [
129+ { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, { button: 2 },
130+ ];
131+ for (const mods of cases) {
132+ const body = table('P/');
133+ assert.equal(click(body.children[0], mods), true, JSON.stringify(mods) + ' is not intercepted');
134+ assert.equal(pending.size, 0, 'no request');
135+ assert.equal(body.children[0].getAttribute('aria-busy'), null);
136+ }
137+ assert.equal(click(table('P/').children[0]), false, 'a plain left click is intercepted');
138+});
139+
140+test('a second click while the rows are loading is ignored', async () => {
141+ pending.clear();
142+ const body = table('P/');
143+ let requests = 0;
144+ const fetch0 = globalThis.fetch;
145+ globalThis.fetch = (url) => { requests++; return fetch0(url); };
146+ click(body.children[0]);
147+ click(body.children[0]);
148+ assert.equal(requests, 1);
149+ await answer('/rows/P/', 'a');
150+ globalThis.fetch = fetch0;
151+ assert.deepEqual(shown(body), ['P/', 'a']);
152+});
153+
154+test('a failed request navigates to the directory', async () => {
155+ pending.clear();
156+ const body = table('P/');
157+ globalThis.location.href = '';
158+ click(body.children[0]);
159+ await answer('/rows/P/', 'oops', false);
160+ assert.equal(globalThis.location.href, '/tree/P/');
161+ assert.deepEqual(body.children.map((r) => r.name), ['P/']);
162+});
163+
164+test('the tree-more row has a depth and follows its directory', async () => {
165+ pending.clear();
166+ const body = table('P/', 'f');
167+ click(body.children[0]);
168+ await answer('/rows/P/', 'a,more');
169+ const more = byName(body, 'more');
170+ assert.equal(more.dataset.depth, '1');
171+ assert.equal(more.style.props['--depth'], '1');
172+ click(body.children[0]);
173+ assert.equal(more.hidden, true, 'hidden with its directory');
174+ assert.deepEqual(shown(body), ['P/', 'f']);
175+ click(body.children[0]);
176+ assert.equal(more.hidden, false, 'shown with it again');
177+});
@@ -3,86 +3,23 @@
3 // node --test 'internal/adapters/web/jstest/*.test.mjs'3 // node --test 'internal/adapters/web/jstest/*.test.mjs'
4 import assert from 'node:assert/strict';4 import assert from 'node:assert/strict';
5 import { test } from 'node:test';5 import { test } from 'node:test';
6+import { El, el, installDom } from './dom.mjs';
6 7
7-// ---- a DOM just big enough for ermiteTree ----------------------------------8+// A row is a name: a trailing "/" makes a directory with a link, "!x" a div
8-9+// that is not a row, "more" the tree-more row of a truncated directory.
9-class El {
10- constructor(tag) {
11- this.tagName = tag.toUpperCase();
12- this.attrs = new Map();
13- this.dataset = {};
14- this.children = [];
15- this.parent = null;
16- this.hidden = false;
17- this.style = { setProperty() {} };
18- }
19- setAttribute(k, v) { this.attrs.set(k, String(v)); }
20- getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
21- hasAttribute(k) { return this.attrs.has(k); }
22- removeAttribute(k) { this.attrs.delete(k); }
23- append(c) { c.parent = this; this.children.push(c); }
24- get nextElementSibling() {
25- if (!this.parent) return null;
26- return this.parent.children[this.parent.children.indexOf(this) + 1] || null;
27- }
28- after(...rows) {
29- const sib = this.parent.children;
30- for (const r of rows) r.parent = this.parent;
31- sib.splice(sib.indexOf(this) + 1, 0, ...rows);
32- }
33- closest(sel) {
34- for (let e = this; e; e = e.parent) if (e.matches(sel)) return e;
35- return null;
36- }
37- matches(sel) {
38- if (sel === 'tr') return this.tagName === 'TR';
39- if (sel === 'a[data-expand]') return this.tagName === 'A' && 'expand' in this.dataset;
40- return false;
41- }
42- querySelector(sel) {
43- for (const c of this.children) {
44- if (c.matches(sel) || (sel === '.c-kind' && c.cls === 'c-kind')) return c;
45- const d = c.querySelector(sel);
46- if (d) return d;
47- }
48- return null;
49- }
50-}
51-
52-// row makes a table row; a name ending in "/" is a directory with a link.
53 function row(name, depth) {10 function row(name, depth) {
54- const tr = new El('tr');11+ const tr = el('tr', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
55- tr.dataset.depth = String(depth);
56 tr.name = name;12 tr.name = name;
57- const kind = new El('span');13+ tr.append(el('td', { cls: 'c-kind', text: '▸' }));
58- kind.cls = 'c-kind';
59- kind.textContent = '▸';
60- tr.append(kind);
61 if (name.endsWith('/')) {14 if (name.endsWith('/')) {
62- const a = new El('a');15+ tr.append(el('a', { href: '/tree/' + name, attrs: { 'data-expand': '/rows/' + name, 'aria-expanded': 'false' } }));
63- a.dataset.expand = '/rows/' + name;
64- a.href = '/tree/' + name;
65- a.setAttribute('aria-expanded', 'false');
66- tr.append(a);
67 }16 }
68 return tr;17 return tr;
69 }18 }
70 19
71-// A fragment is "x,y/,!z": ! makes a div, not a row.20+installDom({
72-globalThis.document = {21+ parseHTML: (text) => text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0))),
73- createElement(tag) {22+});
74- assert.equal(tag, 'template');
75- const t = { content: { children: [] } };
76- Object.defineProperty(t, 'innerHTML', {
77- set(text) {
78- t.content.children = text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0)));
79- },
80- });
81- return t;
82- },
83-};
84-globalThis.location = { href: '' };
85-globalThis.window = globalThis;
86 await import('../static/js/files.js');23 await import('../static/js/files.js');
87 const T = window.ermiteTree;24 const T = window.ermiteTree;
88 25
@@ -96,16 +33,18 @@ const answer = async (url, text, ok) => {
96 await new Promise((r) => setTimeout(r, 0));33 await new Promise((r) => setTimeout(r, 0));
97 };34 };
98 35
99-const click = (tr) => {36+// click sends a click on the link of tr; it answers whether the page's own
37+// navigation was kept (no preventDefault).
38+const click = (tr, mods = {}) => {
100 const a = tr.querySelector('a[data-expand]');39 const a = tr.querySelector('a[data-expand]');
101- T.click({ target: a, button: 0, preventDefault() {} });40+ let prevented = false;
41+ T.click({ target: a, button: 0, preventDefault() { prevented = true; }, ...mods });
42+ return !prevented;
102 };43 };
103 const kindOf = (tr) => tr.querySelector('.c-kind').textContent;44 const kindOf = (tr) => tr.querySelector('.c-kind').textContent;
104 45
105 function table(...names) {46 function table(...names) {
106- const body = new El('tbody');47+ return el('tbody', {}, ...names.map((n) => row(n, 0)));
107- for (const n of names) body.append(row(n, 0));
108- return body;
109 }48 }
110 const byName = (body, n) => body.children.find((r) => r.name === n);49 const byName = (body, n) => body.children.find((r) => r.name === n);
111 const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);50 const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
@@ -183,3 +122,56 @@ test('TS-006: only rows are kept; no row falls back to navigating', async () =>
183 assert.equal(globalThis.location.href, '/tree/Q/');122 assert.equal(globalThis.location.href, '/tree/Q/');
184 assert.deepEqual(other.children.map((r) => r.name), ['Q/']);123 assert.deepEqual(other.children.map((r) => r.name), ['Q/']);
185 });124 });
125+
126+test('a modified or non-left click keeps the link navigation', () => {
127+ pending.clear();
128+ const cases = [
129+ { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, { button: 2 },
130+ ];
131+ for (const mods of cases) {
132+ const body = table('P/');
133+ assert.equal(click(body.children[0], mods), true, JSON.stringify(mods) + ' is not intercepted');
134+ assert.equal(pending.size, 0, 'no request');
135+ assert.equal(body.children[0].getAttribute('aria-busy'), null);
136+ }
137+ assert.equal(click(table('P/').children[0]), false, 'a plain left click is intercepted');
138+});
139+
140+test('a second click while the rows are loading is ignored', async () => {
141+ pending.clear();
142+ const body = table('P/');
143+ let requests = 0;
144+ const fetch0 = globalThis.fetch;
145+ globalThis.fetch = (url) => { requests++; return fetch0(url); };
146+ click(body.children[0]);
147+ click(body.children[0]);
148+ assert.equal(requests, 1);
149+ await answer('/rows/P/', 'a');
150+ globalThis.fetch = fetch0;
151+ assert.deepEqual(shown(body), ['P/', 'a']);
152+});
153+
154+test('a failed request navigates to the directory', async () => {
155+ pending.clear();
156+ const body = table('P/');
157+ globalThis.location.href = '';
158+ click(body.children[0]);
159+ await answer('/rows/P/', 'oops', false);
160+ assert.equal(globalThis.location.href, '/tree/P/');
161+ assert.deepEqual(body.children.map((r) => r.name), ['P/']);
162+});
163+
164+test('the tree-more row has a depth and follows its directory', async () => {
165+ pending.clear();
166+ const body = table('P/', 'f');
167+ click(body.children[0]);
168+ await answer('/rows/P/', 'a,more');
169+ const more = byName(body, 'more');
170+ assert.equal(more.dataset.depth, '1');
171+ assert.equal(more.style.props['--depth'], '1');
172+ click(body.children[0]);
173+ assert.equal(more.hidden, true, 'hidden with its directory');
174+ assert.deepEqual(shown(body), ['P/', 'f']);
175+ click(body.children[0]);
176+ assert.equal(more.hidden, false, 'shown with it again');
177+});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close