1// Tests of the one-line chip lists of static/js/ermite.js (window.ermiteFit).
2// Only that block is evaluated, on a list whose layout is computed here:
3// chips flow left to right in a row of a given width and wrap, as flexbox
4// does. Run with
5// node --test 'internal/adapters/web/jstest/*.test.mjs'
6import assert from 'node:assert/strict';
7import { readFileSync } from 'node:fs';
8import { test } from 'node:test';
9
10const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
11const start = src.indexOf(' window.ermiteFit = ');
12const block = src.slice(start, src.indexOf('})();', start) + 5);
13
14const GAP = 6;
15const CHAR = 8;
16const PAD = 20;
17
18// A row of chips of the given width (ul.width, which a test may change); a
19// chip is as wide as its text. Only what the block uses is there.
20function list(width, names) {
21 const attrs = {};
22 const ul = {
23 width,
24 children: [],
25 hasAttribute: (k) => k in attrs,
26 setAttribute: (k, v) => { attrs[k] = v; },
27 querySelector(sel) {
28 assert.equal(sel, ':scope > li.more');
29 return this.children.find((c) => c.className === 'more') || null;
30 },
31 append(li) { li.parent = this; this.children.push(li); },
32 };
33 const layout = () => {
34 let x = 0, y = 0;
35 for (const c of ul.children) {
36 if (c.hidden) { c.top = 0; continue; }
37 const w = c.textContent.length * CHAR + PAD;
38 if (!('data-open' in attrs) && x > 0 && x + w > ul.width) { x = 0; y += 28; }
39 c.top = y;
40 x += w + GAP;
41 }
42 };
43 const el = (tag, text = '') => {
44 const e = {
45 tag, className: '', hidden: false, title: '', attrs: {}, kids: [], parent: null, focused: false,
46 get textContent() { return this.kids.length ? this.kids.map((k) => k.textContent).join('') : text; },
47 set textContent(v) { text = v; },
48 get offsetTop() { layout(); return this.top; },
49 setAttribute(k, v) { this.attrs[k] = v; },
50 get firstElementChild() { return this.kids[0] || null; },
51 append(k) { k.parent = this; this.kids.push(k); },
52 remove() { ul.children.splice(ul.children.indexOf(this), 1); },
53 focus() { this.focused = true; globalThis.document.activeElement = this; },
54 closest(sel) {
55 if (sel === '[data-fit]') return ul;
56 if (sel === 'li') return tag === 'li' ? this : this.parent;
57 return null;
58 },
59 };
60 return e;
61 };
62 for (const n of names) ul.append(el('li', n));
63 globalThis.document = { createElement: (tag) => el(tag), activeElement: null };
64 return ul;
65}
66
67function load() {
68 globalThis.window = {};
69 new Function(block)();
70 return globalThis.window.ermiteFit;
71}
72
73const shown = (ul) => ul.children.filter((c) => !c.hidden).map((c) => c.textContent);
74const moreBtn = (ul) => ul.children.find((c) => c.className === 'more')?.kids[0];
75
76test('a list that fits is left alone', () => {
77 const fit = load();
78 const ul = list(400, ['go', 'git', 'forge']);
79 fit.fit(ul);
80 assert.deepEqual(shown(ul), ['go', 'git', 'forge']);
81});
82
83test('the chips past the line give way to a … button that names them', () => {
84 const fit = load();
85 // each chip is 6 chars: 68px, 74px with the gap. In 380px, 5 fit (364px)
86 // but not with the 28px … chip after them: the 5th makes way.
87 const ul = list(380, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg']);
88 fit.fit(ul);
89 assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', '…']);
90 const btn = moreBtn(ul);
91 assert.equal(btn.tag, 'button');
92 assert.equal(btn.type, 'button');
93 assert.equal(btn.title, 'eeeeee, ffffff, gggggg');
94 assert.equal(btn.attrs['aria-label'], 'show 3 more: eeeeee, ffffff, gggggg');
95 for (const c of ul.children.filter((c) => !c.hidden)) assert.equal(c.offsetTop, 0, 'all on the first line');
96
97 // In 400px, the 5th chip and the … chip both fit.
98 const roomy = list(400, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff']);
99 fit.fit(roomy);
100 assert.deepEqual(shown(roomy), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', '…']);
101});
102
103test('fitting again after a resize shows what fits now, with one … button', () => {
104 const fit = load();
105 const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
106 const ul = list(380, names);
107 fit.fit(ul);
108 fit.fit(ul);
109 assert.equal(ul.children.filter((c) => c.className === 'more').length, 1, 'idempotent');
110
111 ul.width = 2000; // wider: everything shows, no … button
112 fit.fit(ul);
113 assert.deepEqual(shown(ul), names);
114 assert.equal(moreBtn(ul), undefined);
115
116 ul.width = 250; // narrower again: fewer chips, the label follows
117 fit.fit(ul);
118 assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', '…']);
119 assert.equal(moreBtn(ul).attrs['aria-label'], 'show 4 more: dddddd, eeeeee, ffffff, gggggg');
120});
121
122test('a refit keeps the … button, and its focus', () => {
123 const fit = load();
124 const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
125 const ul = list(380, names);
126 fit.fit(ul);
127 const btn = moreBtn(ul);
128 btn.focus();
129 ul.width = 250;
130 fit.fit(ul);
131 assert.equal(moreBtn(ul), btn, 'the same button');
132 assert.equal(document.activeElement, btn);
133 ul.width = 2000; // all fit: the focus goes to the last chip
134 fit.fit(ul);
135 assert.equal(document.activeElement.textContent, 'gggggg');
136});
137
138test('pressing … shows every chip and moves the focus to the first one it showed', () => {
139 const fit = load();
140 const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
141 const ul = list(380, names);
142 fit.fit(ul);
143 fit.open(moreBtn(ul));
144 assert.deepEqual(shown(ul), names, 'all shown, no … left');
145 const e = ul.children.find((c) => c.textContent === 'eeeeee');
146 assert.ok(e.focused);
147 assert.equal(e.attrs.tabindex, '-1');
148 fit.fit(ul); // a resize keeps it open
149 assert.deepEqual(shown(ul), names);
150});