-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest-ui.html
More file actions
278 lines (249 loc) · 11.8 KB
/
Copy pathtest-ui.html
File metadata and controls
278 lines (249 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WordTools UI tests</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%23b59f3b'/%3E%3Ctext x='16' y='23' font-family='monospace' font-size='19' font-weight='700' fill='white' text-anchor='middle'%3EU%3C/text%3E%3C/svg%3E">
<style>
body { font: 14px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
margin: 1.5rem auto; max-width: 66rem; padding: 0 1rem;
background: #14161a; color: #d7dbe0; }
h1 { font-size: 1.05rem; letter-spacing: .04em; text-transform: uppercase; color: #8b93a1; }
#summary { font-size: 1.3rem; margin: .75rem 0 1rem; }
.ok { color: #5ec27a; } .bad { color: #f2777a; }
.note { color: #8b93a1; font-size: .85rem; max-width: 46rem; }
.group { color: #8b93a1; font-weight: 700; letter-spacing: .05em; text-transform: uppercase;
font-size: .78rem; margin: 1.5rem 0 .35rem; }
.row { padding: .15rem 0; border-bottom: 1px solid #22262d; display: flex; gap: .75rem; }
.row .mark { width: 3.2rem; flex: none; }
.row .name { flex: 1; }
.row .why { color: #f2777a; white-space: pre; }
iframe { width: 720px; height: 520px; border: 1px solid #2e333a; border-radius: 6px;
margin-top: 1.5rem; background: #14161a; }
</style>
</head>
<body>
<h1>WordTools UI tests</h1>
<div id="summary">running…</div>
<p class="note">
Drives the real page with synthetic input, keyboard and click events. Covers the
interactions that <a href="test.html" style="color:#7ab8ff">test.html</a> cannot
reach, since that one only exercises the DOM-free engine.
<br><br>
<b>Run this in a normal browser window.</b> Headless Chromium with
<code>--virtual-time-budget</code> fast-forwards timers without producing frames,
so the app's requestAnimationFrame-batched render never runs and every colour
assertion fails for the wrong reason. Drive it headlessly with a real-time run
instead: <code>msedge --headless=new http://localhost:8000/test-ui.html</code>
and read the result from the server log.
</p>
<div id="out"></div>
<iframe id="f" title="app under test"></iframe>
<script type="module">
const results = [];
let group = '';
const g = (name) => { group = name; };
function check(name, actual, expected) {
const pass = actual === expected;
results.push({ group, name, pass, why: pass ? '' : `got ${actual}\nwant ${expected}` });
}
const f = document.getElementById('f');
// src is assigned here rather than in the markup: this module is deferred, so an
// inline src would already have loaded and the load event would never fire.
const loaded = new Promise((r) => { f.onload = r; });
f.src = 'index.html#l=5';
await loaded;
const doc = f.contentDocument;
const win = f.contentWindow;
const $ = (sel) => doc.querySelector(sel);
const tiles = () => [...doc.querySelectorAll('#tiles .tile')];
const input = (i) => tiles()[i].querySelector('input');
const settle = () => new Promise((r) => setTimeout(r, 60));
// Wait for the dictionary rather than guessing at a delay.
for (let i = 0; i < 100 && !/loaded/.test($('#status').textContent); i++) await settle();
/** Read the row back as "letter:colourInitial" per tile. */
const readRow = () => tiles().map((t) => {
const v = t.querySelector('input').value;
return v ? `${v}:${(t.dataset.color || '?')[0]}` : '_';
}).join(' ');
const focused = () => {
const a = doc.activeElement;
return a && a.dataset && a.dataset.i !== undefined ? +a.dataset.i : null;
};
const chips = (sel) => [...doc.querySelectorAll(`${sel} .chip b`)].map((b) => b.textContent).join(',');
/**
* Type a character the way a browser would: when the field's text is selected
* (as it is straight after focus) the keystroke replaces it, otherwise it appends.
*/
async function type(i, ch, { selected = false } = {}) {
const el = input(i);
el.value = selected ? ch : el.value + ch;
el.dispatchEvent(new win.InputEvent('input', { bubbles: true }));
await settle();
}
async function key(i, k) {
input(i).dispatchEvent(new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
await settle();
}
async function clickTile(i) {
const el = input(i);
el.focus(); // a real click focuses first
el.dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
await settle();
}
async function clickKey(letter) {
[...doc.querySelectorAll('#keyboard button')]
.find((b) => b.dataset.letter === letter)
.dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
await settle();
}
// ------------------------------------------------------------- typing
g('typing');
input(0).focus();
await settle();
await type(0, 's', { selected: true });
check('typing does not advance — cursor stays so the colour can be set',
`${readRow()} focus=${focused()}`, 's:g _ _ _ _ focus=0');
await key(0, 'ArrowUp');
check('ArrowUp colours in place', `${tiles()[0].dataset.color} focus=${focused()}`,
'yellow focus=0');
await key(0, 'ArrowUp');
check('ArrowUp again reaches green', tiles()[0].dataset.color, 'green');
await key(0, 'ArrowDown');
check('ArrowDown walks back', tiles()[0].dataset.color, 'yellow');
await key(0, 'ArrowUp');
await type(0, 'a');
check('the NEXT letter is what advances', `${readRow()} focus=${focused()}`,
's:g a:g _ _ _ focus=1');
check('the earlier tile keeps its colour', tiles()[0].dataset.color, 'green');
check('a new letter starts gray', tiles()[1].dataset.color, 'gray');
await type(1, 'n');
await type(2, 'd');
await type(3, 'y');
check('typing straight through', `${readRow()} focus=${focused()}`,
's:g a:g n:g d:g y:g focus=4');
// ------------------------------------------------------- fixing a mistake
g('editing');
await clickTile(1);
check('clicking a tile cycles its colour and moves focus there',
`${tiles()[1].dataset.color} focus=${focused()}`, 'yellow focus=1');
await type(1, 'x', { selected: true });
check('typing on a clicked tile replaces in place, does not run on',
`${readRow()} focus=${focused()}`, 's:g x:g n:g d:g y:g focus=1');
check('retyping resets that tile to gray', tiles()[1].dataset.color, 'gray');
await key(1, 'Backspace');
check('Backspace clears and stays', `${readRow()} focus=${focused()}`,
's:g _ n:g d:g y:g focus=1');
await key(1, 'Backspace');
check('Backspace on an empty tile steps back', `${readRow()} focus=${focused()}`,
'_ _ n:g d:g y:g focus=0');
await type(0, 'a', { selected: true });
check('typing after Backspace refills the same tile', `${readRow()} focus=${focused()}`,
'a:g _ n:g d:g y:g focus=0');
await clickTile(4);
await type(4, 'z', { selected: true });
await type(4, 'w');
check('the last tile has nowhere to run on to, so it replaces',
`${readRow()} focus=${focused()}`, 'a:g _ n:g d:g w:g focus=4');
// --------------------------------------------------------------- reset
g('reset');
$('#reset').dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
await settle();
check('Reset empties the row', readRow(), '_ _ _ _ _');
// ------------------------------------------------------- the A-Z keyboard
g('letter panel');
await clickKey('s');
check('one click adds to "also contains"', chips('#include-chips'), 's');
await clickKey('s');
check('a second click moves it to "no more of"',
`${chips('#include-chips')}|${chips('#nomore-chips')}`, '|s');
await clickKey('s');
check('a third click clears it',
`${chips('#include-chips')}|${chips('#nomore-chips')}`, '|');
await clickKey('e');
await clickKey('q'); await clickKey('q');
check('the two lists are mutually exclusive',
`${chips('#include-chips')}|${chips('#nomore-chips')}`, 'e|q');
// --------------------------------------------------- results actually update
g('results');
$('#reset').dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
await settle();
const all5 = $('#count').textContent;
check('an empty query lists every five-letter word', all5, '9,476 words');
// The scenario the additive panel exists for: SA__Y green, N and D gray.
input(0).focus(); await settle();
for (const [i, ch] of [...'sandy'].entries()) {
await type(i, ch, { selected: i === 0 });
}
for (const i of [0, 1, 4]) { await key(i, 'ArrowUp'); await key(i, 'ArrowUp'); }
check('SA__Y with N and D gray', readRow(), 's:g a:g n:g d:g y:g');
check('...matches 12 words', $('#count').textContent, '12 words');
await clickKey('s');
check('"also contains S" narrows it to SASSY alone', $('#count').textContent, '1 word');
check('...and that word is SASSY', $('#words button').textContent, 'sassy');
await clickKey('s'); // -> "no more of S"
check('"no more of S" drops SASSY instead', $('#count').textContent, '11 words');
// ------------------------------------------------------------ conflicts
g('conflicts');
$('#reset').dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
await settle();
input(0).focus(); await settle();
await type(0, 'q', { selected: true }); // gray Q
await clickKey('q'); // ...but "also contains Q"
check('a contradiction shows the banner, not a bare zero',
`${$('#banner').hidden} ${/cannot all be true/.test($('#banner').textContent)}`,
'false true');
check('...and reports zero words', $('#count').textContent, '0 words');
// ------------------------------------------------------------ word length
g('length');
$('#reset').dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
await settle();
async function bumpLength(sel, times) {
for (let i = 0; i < times; i++) {
$(sel).dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
}
await settle();
}
await bumpLength('#len-dec', 3); // 5 -> 3, with one press past the floor
check('length stops at three — two-letter words are not in the list',
`${$('#len-val').value} ${tiles().length}`, '3 3');
await bumpLength('#len-inc', 13); // 3 -> 15, again one press past the end
check('length tops out at fifteen', `${$('#len-val').value} ${tiles().length}`, '15 15');
// The tiles shrink to fit rather than wrapping, however long the word is.
const wide = tiles();
check('fifteen tiles stay on one line',
wide.every((t) => t.offsetTop === wide[0].offsetTop), true);
check('...without overflowing the row',
$('#tiles').scrollWidth <= $('#tiles').clientWidth, true);
const nhBoxes = [...doc.querySelectorAll('#nothere-row .nh')];
check('...and the not-here strip still lines up with them',
nhBoxes.map((b, i) => Math.round(
b.getBoundingClientRect().left - wide[i].getBoundingClientRect().left)).join(','),
new Array(15).fill('0').join(','));
// ---------------------------------------------------------------- render
const out = document.getElementById('out');
let last = null;
for (const r of results) {
if (r.group !== last) {
last = r.group;
out.insertAdjacentHTML('beforeend', `<div class="group">${r.group}</div>`);
}
out.insertAdjacentHTML('beforeend',
`<div class="row"><span class="mark ${r.pass ? 'ok' : 'bad'}">${r.pass ? 'PASS' : 'FAIL'}</span>` +
`<span class="name">${r.name}</span>` +
(r.pass ? '' : `<span class="why">${r.why.replace(/</g, '<')}</span>`) + '</div>');
}
const failed = results.filter((r) => !r.pass).length;
const summary = failed ? `${failed} of ${results.length} failed` : `all ${results.length} passed`;
document.getElementById('summary').innerHTML =
`<span class="${failed ? 'bad' : 'ok'}">${summary}</span>`;
document.title = failed ? `${failed} FAILED — WordTools UI tests` : summary;
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
const detail = results.filter((r) => !r.pass)
.map((r) => `${r.group}/${r.name}: ${r.why.replace(/\n/g, ' ')}`).join(' ;; ');
fetch(`/__uitests?pass=${results.length - failed}&fail=${failed}&detail=` +
encodeURIComponent(detail)).catch(() => {});
}
</script>
</body>
</html>