test(web): cover the finder and tree DOM glue
- commit
- 9494523d0406b2b4752a3a4095a1e960747d726e
- author
- brice · · 2026-10-12 05:03 UTC
- parent
- 84a2d29
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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); | |||
| 25 | 25 | const top = (q, k = 50, prev = null) => F.search(idx, q, k, prev); | |
| 26 | 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 | 28 | test('search: rejects what is not a subsequence', () => { | |
| 43 | 29 | assert.deepEqual(names(top('zzz')), []); | |
| 44 | 30 | assert.deepEqual(names(top('gnirt')), [], 'order matters'); | |
| 45 | 31 | assert.equal(top('zzz').all.length, 0); | |
| 46 | 32 | assert.deepEqual(names(top('')), []); | |
| 47 | 33 | 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'); | |
| 50 | 35 | }); | |
| 51 | 36 | ||
| 52 | 37 | test('search: ranking', () => { | |
| @@ -91,14 +76,6 @@ test('search: several terms', () => { | |||
| 91 | 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 | 79 | test('search: top k, ties by length then path', () => { | |
| 103 | 80 | const ps = []; | |
| 104 | 81 | for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt'); | |
| @@ -161,16 +138,32 @@ test('positions', () => { | |||
| 161 | 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', () => { | |
| 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'); | |
| 174 | 167 | }); | |
| 175 | 168 | ||
| 176 | 169 | 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 @@ | |||
| 3 | 3 | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | 4 | import assert from 'node:assert/strict'; | |
| 5 | 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 | - | ||
| 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. | |
| 53 | 10 | 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 } }); | |
| 56 | 12 | 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: '▸' })); | |
| 61 | 14 | 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' } })); | |
| 67 | 16 | } | |
| 68 | 17 | return tr; | |
| 69 | 18 | } | |
| 70 | 19 | ||
| 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 | + | }); | |
| 86 | 23 | await import('../static/js/files.js'); | |
| 87 | 24 | const T = window.ermiteTree; | |
| 88 | 25 | ||
| @@ -96,16 +33,18 @@ const answer = async (url, text, ok) => { | |||
| 96 | 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 | 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 | 44 | const kindOf = (tr) => tr.querySelector('.c-kind').textContent; | |
| 104 | 45 | ||
| 105 | 46 | 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))); | |
| 109 | 48 | } | |
| 110 | 49 | const byName = (body, n) => body.children.find((r) => r.name === n); | |
| 111 | 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 | 122 | assert.equal(globalThis.location.href, '/tree/Q/'); | |
| 184 | 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 | + | }); | |
| @@ -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 | + | }); | |||