Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_tags.mjs

65.8 KiB, 1 run

created by r2519314175:725, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// Verify Diamond tags: chips, search, the standing tag pool, the tag editor,
2// persistence, and the backup round trip -- against the real wasm, no stubs.
3//
4// Tags are the user's filing system and nothing else, so the check that
5// matters most is the one proving a tag never reaches the model.
6//
7// The filter used to be drawn only once a tag had been clicked, and the only
8// chip to click sat on a Diamond box -- so the space under the search box, which
9// is where a filter goes, was empty until you had already found another way in.
10// It is a POOL now: every tag in use, each chip cycling off -> wanted ->
11// refused -> off where it sits. The checks that used to read a summary read the
12// pool instead; the ones that asserted a summary chip's closer now assert the
13// stronger thing, that a pool chip's text is the bare tag.
14//
15// The pool STOOD, and that was a rent it could not pay: thirty tags is three and
16// a half rows of chips under the search box on every screen, whether anything is
17// being filtered or not, and the rail's two lists live on what is left. It sits
18// behind a DISCLOSURE now -- one muted row naming the feature and counting what
19// is behind it, in the place and the ink of the "No tags yet" line, so the
20// question "is there a filter here?" is answered before it is asked either way.
21// The checks that asserted the pool stands by default now assert the disclosure
22// stands by default: what is superseded is the pool being permanently up, not
23// the pool. Two properties are new and load-bearing -- the choice is kept across
24// a reload, and a filter that is ON is never invisible, because closed the chips
25// holding it come out and stand on their own.
26
27import fs from 'node:fs';
28import { open, shot, errors, signInAs, mockLog, clearMockLog, scratch } from './harness.mjs';
29
30const s = await open({ name: 'tags', defaults: false });
31const { page } = s;
32const out = [];
33const say = (ok, what) => { out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); return ok; };
34let bad = 0;
35const check = (ok, what) => { if (!say(ok, what)) bad++; };
36// A failure that is real, understood, and not this change's. Reported loudly,
37// but it does not fail the run: see the note at the backup round trip.
38const known = [];
39const checkKnown = (ok, what, why) => { if (!ok) known.push(`${what}\n ${why}`); };
40
41const boxes = () => page.$$eval('.diamond-box .session-box-name', els => els.map(e => e.textContent));
42const railOf = () => page.$$eval('.diamond-box', els => els.map(e => ({
43 name: (e.querySelector('.session-box-name') || {}).textContent,
44 tags: [...e.querySelectorAll('.session-box-tags .tag-chip')].map(c => c.textContent),
45 more: (e.querySelector('.tag-more') || {}).textContent || null,
46})));
47
48/// Reach the real wasm directly. The page has already run init(), so a fresh
49/// DaimondApp shares its memory and its OPFS -- this is the same store the UI
50/// is reading, not a copy.
51const wasm = (fn, arg) => page.evaluate(async ({ src, arg }) => {
52 const m = await import('/pkg/oxedyne_daimond.js');
53 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
54 return await (new Function('app', 'arg', `return (${src})(app, arg);`))(app, arg);
55}, { src: fn.toString(), arg });
56
57/// How the rail's two lists are sharing it. The furniture above them is cut out
58/// of the same column, so anything that changes the furniture's height has to
59/// make that cut again or the divider walks.
60const shareOn = (pg) => pg.evaluate(() => {
61 const l = document.getElementById('diamond-list').getBoundingClientRect().height;
62 const c = document.getElementById('session-list').getBoundingClientRect().height;
63 return { list: Math.round(l), sess: Math.round(c), share: +(l / (l + c)).toFixed(3),
64 handle: !!document.getElementById('handle-rail-split') };
65});
66
67/// Open or close the pool, by the one control a user has for it. A no-op when it
68/// is already the way round it was asked for.
69const setPoolOn = async (pg, want) => {
70 const now = await pg.$eval('#diamond-filter .tagf-toggle',
71 b => b.getAttribute('aria-expanded') === 'true').catch(() => null);
72 if (now === null || now === want) return now;
73 await pg.click('#diamond-filter .tagf-toggle', { force: true });
74 await pg.waitForTimeout(400);
75 return want;
76};
77
78// An empty account is already told "No Diamonds yet." -- a second line about
79// tags there would be nagging about a thing there is nothing to do it to.
80check(!(await page.isVisible('#diamond-tag-hint')), 'no tag hint on an account with no Diamonds at all');
81
82// ── Create three Diamonds ────────────────────────────────────────────
83async function newDiamond(name) {
84 // NOT `{ force: true }`. A force-click reaches a button a person could not,
85 // and this suite's subject is what the rail does to a Diamond you create --
86 // so "the button was reachable" is part of what it is asserting. The force
87 // hid the Admin drawer sitting over it for the last two sessions.
88 await page.click('#new-diamond-btn', { timeout: 8000 });
89 await page.waitForSelector('.dlg-input', { timeout: 10000 });
90 await page.fill('.dlg-input', name);
91 await page.click('.dlg-ok', { force: true });
92 await page.waitForTimeout(700);
93}
94for (const n of ['Ship a CSV parser', 'Mum birthday plan', 'Rust compiler notes']) await newDiamond(n);
95check((await boxes()).length === 3, 'three Diamonds in the rail');
96
97// The contract says every row now carries tags, [] when there are none.
98const rawRows = await wasm(async (app) => JSON.parse(await app.list_diamonds()));
99check(rawRows.length === 3 && rawRows.every(r => Array.isArray(r.tags) && r.tags.length === 0),
100 'list_diamonds rows carry "tags":[] when a Diamond has none');
101
102// An untagged Diamond must look exactly as it did before tags existed.
103//
104// THE WHOLE TILE, not the meta row. This read `.session-box-meta`'s innerHTML
105// until 2026-08-21, and on 2026-08-20 the chips moved out of that row onto one of
106// their own -- so the check went on passing while asking a question whose answer
107// had become "no" by construction. A chip could have been drawn on every untagged
108// Diamond in the rail and this would have said nothing.
109const untaggedMeta = await page.$$eval('.diamond-box', els =>
110 els.map(e => e.innerHTML || ''));
111check(untaggedMeta.every(h => !h.includes('tag-chip')), 'untagged Diamonds render no chips (zero regression)');
112
113// ── The tag pool's empty state ───────────────────────────────────
114// Every chip in the rail is drawn from a Diamond, so a store with no tag on
115// anything draws none, and a rail that is just a search box reads as filing
116// that was taken away rather than filing not yet done. That is how the tag
117// loss was read, twice. Say which, where the chips would be.
118const hintText = () => page.$eval('#diamond-tag-hint', e => e.textContent).catch(() => '');
119check(await page.isVisible('#diamond-tag-hint'),
120 'a store with Diamonds but no tags shows a hint where the chips would be');
121check((await hintText()).includes('No tags yet'),
122 `the hint says the tags are missing, not the feature: ${JSON.stringify(await hintText())}`);
123// It must not sit anywhere a chip's text is read from: the filter chip's text
124// IS a tag name, and the list's text is read for "No Diamonds match".
125check(await page.$eval('#diamond-tag-hint',
126 e => !e.closest('#diamond-filter') && !e.closest('#diamond-list')).catch(() => false),
127 'the hint sits outside the filter chip and outside the Diamonds list');
128check(!(await page.isVisible('#diamond-filter')), 'and it does not raise an empty filter chip beside it');
129// The route to a first tag, for the reader who wants it, in the order the pool
130// really offers them -- a hint promising chips the editor does not offer would
131// be the same lie in a smaller font.
132// The starters are translated, so they are read from the table rather than
133// spelled out here: a test that hardcodes the English four passes in English
134// and fails in the other seven for no reason but its own assumption.
135const STARTERS = fs.readFileSync(new URL('../www/i18n/en.js', import.meta.url), 'utf8')
136 .match(/'tag\.starters':\s*'([^']*)'/)[1]
137 .split(',').map(x => x.trim()).filter(Boolean);
138const hintTip = await page.$eval('#diamond-tag-hint', e => e.title).catch(() => '');
139check(hintTip.includes(STARTERS.join(', ')),
140 `the hint's tooltip names the starter tags in the pool's order: ${JSON.stringify(hintTip)}`);
141await shot(s, 'tags-empty-hint');
142
143// The order tagging must not disturb.
144const orderBefore = await boxes();
145
146// ── Tag the first Diamond through the editor ───────────────────────
147async function openTagEditor(name) {
148 const idx = (await boxes()).indexOf(name);
149 await page.$$eval('.diamond-box', (els, i) => els[i].click(), idx);
150 await page.waitForTimeout(500);
151 for (const b of await page.$$('.crystal-act')) {
152 if ((await b.textContent()).includes('Tags')) { await b.click({ force: true }); break; }
153 }
154 await page.waitForSelector('.tag-editor', { timeout: 5000 });
155}
156const editorTags = () => page.$$eval('.tag-row:not(.tag-sug) .tag-chip',
157 els => els.map(e => e.textContent.replace('×', '')));
158
159await openTagEditor('Ship a CSV parser');
160check(await page.isVisible('.tag-editor'), 'tag editor opens from the crystal bar');
161const sugs = await page.$$eval('.tag-sug .tag-chip', els => els.map(e => e.textContent));
162check(JSON.stringify(sugs) === JSON.stringify(STARTERS),
163 `default suggestions offered: ${JSON.stringify(sugs)}`);
164
165// One click adds a suggestion.
166const sugChips = await page.$$('.tag-sug .tag-chip');
167await sugChips[0].click({ force: true });
168await page.waitForTimeout(600);
169// Type a custom one, shouty and padded, to prove the real normalisation lands.
170await page.fill('.tag-input', ' RUST ');
171await page.keyboard.press('Enter');
172await page.waitForTimeout(600);
173const et = await editorTags();
174check(et.includes('person') && et.includes('rust'), `editor shows current tags, normalised: ${JSON.stringify(et)}`);
175
176// ── The two boxes: a closed chip lands back in the pool ──────────
177// Close "rust" above; it must leave the Diamond AND reappear below, where
178// one click brings it back -- the pool holds every tag, not a fixed list.
179await page.click('.tag-chip.tag-edit .tag-x >> nth=-1', { force: true }); // rust is newest, last
180await page.waitForTimeout(600);
181const afterClose = await editorTags();
182const pool = await page.$$eval('.tag-sug .tag-chip', els => els.map(e => e.textContent));
183check(!afterClose.includes('rust') && pool.includes('rust'),
184 `a closed tag returns to the pool: on=${JSON.stringify(afterClose)} pool has rust=${pool.includes('rust')}`);
185const rustChip = await page.$$('.tag-sug .tag-chip');
186for (const c of rustChip) {
187 if ((await c.textContent()) === 'rust') { await c.click({ force: true }); break; }
188}
189await page.waitForTimeout(600);
190check((await editorTags()).includes('rust'), 'one click in the pool restores it');
191
192// ── Tagging must not reorder the rail ────────────────────────────
193// The Rust set_tags deliberately leaves `updated` alone: filing a Diamond is not
194// touching it, and a rail that reshuffles under a tag edit is a rail you
195// cannot tag twice without losing your place.
196await page.click('.crystal-act', { force: true }); // ← Back to the crystal
197await page.waitForTimeout(500);
198const orderAfter = await boxes();
199check(JSON.stringify(orderBefore) === JSON.stringify(orderAfter),
200 `rail order is stable across a tag edit: ${JSON.stringify(orderAfter)}`);
201
202// ── The hint goes when there is something to filter with ─────────
203const tagged = (await railOf()).find(r => r.name === 'Ship a CSV parser');
204check(tagged && tagged.tags.length === 2 && !(await page.isVisible('#diamond-tag-hint')),
205 `one tag anywhere replaces the hint with chips: chips=${JSON.stringify(tagged && tagged.tags)}`);
206
207// ── The standing pool ────────────────────────────────────────────
208// The filter used to be drawn only once something had been clicked, and the
209// only thing to click was a chip on a Diamond box. So a reader who went looking
210// for the filter under the search box -- which is where a filter goes -- found
211// nothing there, and concluded there wasn't one. The pool is the surface now:
212// it is up before anything is touched, and it says what the vocabulary is.
213/// Every chip in the pool, in the order the pool offers them.
214const poolText = () => page.$$eval('#diamond-filter .tagf-pool .tag-chip',
215 els => els.map(e => e.textContent));
216/// Each pool chip and the state its class says the rail is holding it in.
217const poolState = () => page.$$eval('#diamond-filter .tagf-pool .tag-chip', els => els.map(e => ({
218 t: e.textContent,
219 s: e.classList.contains('tag-inc') ? 'inc' : e.classList.contains('tag-no') ? 'exc' : 'off',
220})));
221const stateOf = (st, tag) => (st.find(x => x.t === tag) || { s: 'ABSENT' }).s;
222/// One click on a pool chip: the same cycle a chip on a Diamond box walks.
223const poolClick = async (tag) => {
224 const hit = await page.$$eval('#diamond-filter .tagf-pool .tag-chip', (els, n) => {
225 for (const e of els) if (e.textContent === n) { e.click(); return true; }
226 return false;
227 }, tag);
228 if (!hit) check(false, `the pool offers a "${tag}" chip to click`);
229 await page.waitForTimeout(400);
230};
231/// Round the cycle until the tag is filtering nothing. Bounded: the cycle is
232/// three legs, so a fourth click would mean it does not close.
233const poolOff = async (tag) => {
234 for (let i = 0; i < 3 && stateOf(await poolState(), tag) !== 'off'; i++) await poolClick(tag);
235};
236/// One click on a tag chip on a Diamond BOX -- the other way into the filter,
237/// and the only one there is while the pool is closed.
238const clickTag = async (name) => {
239 await page.$$eval('.session-box-tags .tag-chip', (els, n) => {
240 for (const e of els) if (e.textContent === n) { e.click(); return; }
241 }, name);
242 await page.waitForTimeout(400);
243};
244
245// ── The disclosure the pool sits behind ──────────────────────────
246// One row, always there once a tag is, naming the feature and counting what is
247// behind it. This is the whole of the answer to both faults at once: a reader
248// who goes looking under the search box finds the filter named, and a reader
249// who never touches it pays one line for it instead of four.
250
251/// The disclosure row: what it says, what it is holding, and how much of the
252/// rail it takes -- a label is a word to hit, a row is a row.
253const disc = () => page.evaluate(() => {
254 const b = document.querySelector('#diamond-filter .tagf-toggle');
255 if (!b) return null;
256 const f = document.getElementById('diamond-filter');
257 const chev = document.querySelector('#diamond-filter .tagf-chev');
258 const cs = chev ? getComputedStyle(chev) : null;
259 return {
260 text: b.textContent.trim(),
261 open: b.getAttribute('aria-expanded') === 'true',
262 pool: !!document.querySelector('#diamond-filter .tagf-pool'),
263 w: Math.round(b.getBoundingClientRect().width),
264 fw: Math.round(f.getBoundingClientRect().width),
265 // The chevron must be INK, not a character: borders on an empty box,
266 // turned. A font glyph would be one missing-glyph box away from saying
267 // nothing at all, in a UI that ships eight languages.
268 chev: cs ? { rt: cs.borderRightWidth, bt: cs.borderBottomWidth,
269 tr: cs.transform, txt: chev.textContent } : null,
270 };
271});
272/// What the filter is saying while the pool is CLOSED: the chips that are
273/// holding it, standing on their own.
274const astate = () => page.evaluate(() => {
275 const f = document.getElementById('diamond-filter');
276 const row = f && f.querySelector('.tagf-active');
277 const chips = row ? [...row.querySelectorAll('.tag-chip')] : [];
278 return {
279 row: !!row,
280 pool: !!(f && f.querySelector('.tagf-pool')),
281 ctl: !!(f && f.querySelector('.tagf-ctl')),
282 chips: chips.map(c => ({
283 t: c.textContent,
284 s: c.classList.contains('tag-inc') ? 'inc' : c.classList.contains('tag-no') ? 'exc' : 'off',
285 })),
286 };
287});
288/// One click on a chip in the collapsed active row.
289const actClick = async (tag) => {
290 const hit = await page.$$eval('#diamond-filter .tagf-active .tag-chip', (els, n) => {
291 for (const e of els) if (e.textContent === n) { e.click(); return true; }
292 return false;
293 }, tag);
294 if (!hit) check(false, `the active row offers a "${tag}" chip to click`);
295 await page.waitForTimeout(400);
296};
297
298const d0 = await disc();
299check(!!d0 && !d0.pool,
300 `the pool is DOWN by default -- it is rent the rail pays on every screen: ${JSON.stringify(d0)}`);
301check(!!d0 && d0.open === false, 'and the disclosure says so, in the attribute a screen reader reads');
302check(!!d0 && /Filter by tag/.test(d0.text) && /\(2\)/.test(d0.text),
303 `the row names the feature and counts what is behind it: ${JSON.stringify(d0 && d0.text)}`);
304// The fault this replaces was a feature read as ABSENT, twice. A row that hid
305// its own count would be the same mistake: "Filter by tag" with nothing behind
306// it and "Filter by tag" with twelve are different offers.
307check(!!d0 && d0.w >= d0.fw - 1,
308 `and the whole row is the target, not the words -- at 250px a rail is mostly empty on the right: ${d0 && d0.w}px of ${d0 && d0.fw}px`);
309check(!!d0 && !!d0.chev && parseFloat(d0.chev.rt) > 0 && parseFloat(d0.chev.bt) > 0
310 && d0.chev.tr !== 'none' && d0.chev.txt === '',
311 `the chevron is drawn in CSS, not typed: ${JSON.stringify(d0 && d0.chev)}`);
312check(!!d0 && !/[▸▾►▼▶#⌄›»→]/.test(d0.text),
313 `so no glyph rides in the label's text, where a missing font would eat it: ${JSON.stringify(d0 && d0.text)}`);
314await shot(s, 'tags-pool-collapsed');
315
316// Opening it, and the divider not moving when it does. The furniture grows by
317// a whole pool here; the two lists below are cut out of the same column.
318const shareShut = await shareOn(page);
319await setPoolOn(page, true);
320const dOpen = await disc();
321const shareOpen = await shareOn(page);
322check(!!dOpen && dOpen.open && dOpen.pool, 'one click opens the pool, and the row says which way it is now');
323check(shareOpen.list + shareOpen.sess < shareShut.list + shareShut.sess,
324 `opening it really does take height off the two lists: ${shareShut.list + shareShut.sess}px -> ${shareOpen.list + shareOpen.sess}px`);
325check(Math.abs(shareOpen.share - shareShut.share) <= 0.03,
326 `and the divider holds its share rather than walking: ${shareShut.share} -> ${shareOpen.share}`);
327
328check(await page.isVisible('#diamond-filter .tagf-pool'),
329 'the tag pool is one click under the search box the moment a tag exists');
330check(JSON.stringify(await poolText()) === JSON.stringify(['person', 'rust']),
331 `and holds the tags actually in use, sorted: ${JSON.stringify(await poolText())}`);
332// The editor offers `project`, `topic` and `org` whatever the store holds. The
333// rail must not: filtering on a tag no Diamond carries can only empty the rail,
334// so a chip for one would be a control whose single outcome is nothing.
335check(!(await poolText()).some(x => ['project', 'topic', 'org'].includes(x)),
336 `the starter suggestions nobody has used are NOT offered as filters: ${JSON.stringify(await poolText())}`);
337// The chips are the reason every reader of a chip can strip a closer and be
338// wrong: here there is nothing to strip.
339check((await poolText()).every(x => x === x.trim() && !/[׬]/.test(x)),
340 `a pool chip's text is the bare tag -- no closer, no mark: ${JSON.stringify(await poolText())}`);
341await shot(s, 'tags-pool-idle');
342await shot(s, 'tags-chips-no-hint');
343
344// ── The choice is kept, and a live filter is never invisible ─────
345// Opening the pool is a habit of working, like the ALL/ANY mode, so it lasts
346// past the page. Closing it must not take the FILTER with it: a rail that is
347// hiding Diamonds and will not say why is the fault this whole surface exists
348// to avoid, one size worse than the one it replaced.
349await setPoolOn(page, false);
350await page.reload({ waitUntil: 'domcontentloaded' });
351await signInAs(s, 'tags');
352await page.waitForTimeout(1500);
353const dReload = await disc();
354check(!!dReload && !dReload.open && !dReload.pool,
355 `the pool stays down across a reload, because that is what was chosen: ${JSON.stringify(dReload)}`);
356
357// The one path that reaches the filter with the pool closed: a chip on a
358// Diamond box. It must surface the state where the pool would have shown it.
359await clickTag('person');
360const aInc = await astate();
361check(aInc.row && !aInc.pool && JSON.stringify(aInc.chips) === JSON.stringify([{ t: 'person', s: 'inc' }]),
362 `closed, a filter puts its OWN chips up rather than nothing: ${JSON.stringify(aInc)}`);
363check(aInc.ctl, 'and the controls that are not tags come with them, exactly as they do open');
364// Only "Ship a CSV parser" carries a tag at this point in the pass.
365check((await boxes()).length === 1, 'the filter really is on while the pool is down');
366await shot(s, 'tags-pool-collapsed-active');
367// The same chip, cycling the same way it does in the pool -- it IS a pool chip,
368// standing somewhere else, so there is no second behaviour to keep in step.
369await actClick('person');
370const aExc = await astate();
371check(JSON.stringify(aExc.chips) === JSON.stringify([{ t: 'person', s: 'exc' }])
372 && (await boxes()).length === 2,
373 `a chip in the active row cycles in place, and refusing takes its carrier off the rail: ${JSON.stringify(aExc)}, ${JSON.stringify(await boxes())}`);
374await actClick('person');
375const aOff = await astate();
376check(!aOff.row && !aOff.ctl && !aOff.pool && (await boxes()).length === 3,
377 `and the row goes when the last tag comes off the filter, leaving the one row: ${JSON.stringify(aOff)}`);
378// Opening it while a filter is on shows the state IN the pool, where each chip
379// already carries its own -- so the two rows are never both saying it.
380await clickTag('person');
381await setPoolOn(page, true);
382const aOpen = await astate();
383check(!aOpen.row && aOpen.pool && stateOf(await poolState(), 'person') === 'inc',
384 `open, the active row stands down: the pool chip is already wearing the state: ${JSON.stringify(aOpen)}`);
385await page.click('#diamond-filter .tag-clear-all', { force: true });
386await page.waitForTimeout(400);
387
388// ── And comes back when the last tag goes ────────────────────────
389// The state the user was actually left in when the sync bug ate the tag set:
390// the filing system is empty again, and the rail has to say so rather than
391// look like the feature went with it.
392await openTagEditor('Ship a CSV parser');
393for (let i = 0; i < 2; i++) {
394 await page.click('.tag-row:not(.tag-sug) .tag-x', { force: true });
395 await page.waitForTimeout(600);
396}
397check((await editorTags()).length === 0, `both tags come off: ${JSON.stringify(await editorTags())}`);
398check(await page.isVisible('#diamond-tag-hint'), 'deleting the LAST tag brings the hint back');
399check(!(await page.isVisible('#diamond-filter')),
400 'and the pool goes with it -- an empty pool is a row of nothing, which is what the hint is for');
401// Put them back the way the pool offers them, so the rest of this pass sees
402// the store it expects.
403for (const want of ['person', 'rust']) {
404 for (const c of await page.$$('.tag-sug .tag-chip')) {
405 if ((await c.textContent()) === want) { await c.click({ force: true }); break; }
406 }
407 await page.waitForTimeout(600);
408}
409const restoredTwo = await editorTags();
410check(restoredTwo.includes('person') && restoredTwo.includes('rust') && !(await page.isVisible('#diamond-tag-hint')),
411 `and goes again once a tag is back: ${JSON.stringify(restoredTwo)}`);
412await page.click('.crystal-act', { force: true }); // ← Back to the crystal
413await page.waitForTimeout(500);
414
415// ── Tag the second Diamond, so a filter has something to exclude ───
416await openTagEditor('Mum birthday plan');
417for (const t of ['person', 'family', 'gifts', 'urgent']) {
418 await page.fill('.tag-input', t); await page.keyboard.press('Enter'); await page.waitForTimeout(450);
419}
420await page.click('.crystal-act', { force: true });
421await page.waitForTimeout(500);
422
423// ── Chips in the rail ────────────────────────────────────────────
424const railChips = await railOf();
425const mum = railChips.find(r => r.name === 'Mum birthday plan');
426// EVERY TAG RENDERS AND THE ROW SCROLLS. Until 2026-08-21 this asserted a cap of
427// three chips and a `+N`, which is what the tile drew then. The overflow chip was
428// removed on the owner's instruction, because a tag that is never drawn cannot be
429// clicked back into the filter -- and a Diamond you cannot see the filing of is a
430// Diamond you cannot find by it. The check now asserts the replacement rather than
431// the thing replaced, or it would hold the old design in place the way
432// verify_daimonface's Fold check did.
433check(mum && mum.tags.length === 4 && mum.more === null,
434 `every tag renders and no overflow chip stands in for any: ${JSON.stringify(mum)}`);
435const csv = railChips.find(r => r.name === 'Ship a CSV parser');
436check(csv && csv.tags.length === 2, `chips render on the tagged Diamond: ${JSON.stringify(csv && csv.tags)}`);
437
438// Colour is deterministic and theme-driven, not hardcoded.
439const hues = await page.$$eval('.session-box-tags .tag-chip',
440 els => els.map(e => ({ t: e.textContent, h: e.style.getPropertyValue('--tag-h') })));
441const byTag = {};
442let stable = true;
443for (const { t, h } of hues) { if (byTag[t] && byTag[t] !== h) stable = false; byTag[t] = h; }
444check(stable && hues.every(x => x.h !== ''), `each tag carries one hue: ${JSON.stringify(byTag)}`);
445const personHue = byTag['person'];
446check(new Set(Object.values(byTag)).size > 1, 'different tags get different hues');
447await shot(s, 'tags-dark-rail');
448
449// ── Persistence: the whole point of the Rust side ────────────────
450// Reload and sign back in: tags must come off disk, not out of memory.
451await page.reload({ waitUntil: 'domcontentloaded' });
452await signInAs(s, 'tags');
453await page.waitForTimeout(1500);
454const afterReload = await railOf();
455const mumAfter = afterReload.find(r => r.name === 'Mum birthday plan');
456const csvAfter = afterReload.find(r => r.name === 'Ship a CSV parser');
457check(mumAfter && JSON.stringify(mumAfter.tags) === JSON.stringify(['person', 'family', 'gifts', 'urgent']) && mumAfter.more === null,
458 `tags PERSIST across a page reload: ${JSON.stringify(mumAfter)}`);
459check(csvAfter && csvAfter.tags.length === 2,
460 `every tagged Diamond survives the reload: ${JSON.stringify(csvAfter && csvAfter.tags)}`);
461check(JSON.stringify(afterReload.map(r => r.name)) === JSON.stringify(orderBefore),
462 `rail order survives the reload: ${JSON.stringify(afterReload.map(r => r.name))}`);
463
464// ── Filter by clicking a chip ────────────────────────────────────
465await clickTag('person');
466const filtered = await boxes();
467check(filtered.length === 2 && filtered.includes('Mum birthday plan') && filtered.includes('Ship a CSV parser'),
468 `clicking a chip filters to that tag: ${JSON.stringify(filtered)}`);
469// The pool is the same surface whichever chip started the filter: clicking one
470// on a Diamond box lights that tag WHERE THE POOL ALREADY SHOWS IT.
471check(stateOf(await poolState(), 'person') === 'inc',
472 `a click on a box chip lights that tag in the pool: ${JSON.stringify(await poolState())}`);
473check(await page.$eval('#diamond-filter .tag-chip.tag-inc', e => e.style.getPropertyValue('--tag-h')) === personHue,
474 'the pool chip is the same colour as the tag it stands for');
475// The controls that are NOT tags sit below the pool, and only when there is a
476// filter for them to act on.
477check(await page.isVisible('#diamond-filter .tagf-ctl'),
478 'a filter raises the controls below the pool');
479await shot(s, 'tags-dark-filter');
480// The clear is the one-click way off. A pool chip carries no closer -- its text
481// is the tag and nothing else -- so the cycle takes two clicks to put one tag
482// down, and this is offered for a filter of any size because of it.
483await page.click('#diamond-filter .tag-clear-all', { force: true });
484await page.waitForTimeout(400);
485check((await boxes()).length === 3 && !(await page.isVisible('#diamond-filter .tagf-ctl'))
486 && stateOf(await poolState(), 'person') === 'off',
487 'the clear puts the filter down, and the pool stays up with every chip off');
488
489// ── The boolean the rail actually holds ──────────────────────────
490// One tag was never enough to say "the family things, but not the ones already
491// bought". The rail keeps two lists -- the tags wanted and the tags refused --
492// and a rule for combining the first. Every state below is reached the way a
493// user reaches it: by clicking chips.
494//
495// The fixture here: "Ship a CSV parser" [person, rust], "Mum birthday plan"
496// [person, family, gifts, urgent], "Rust compiler notes" [].
497
498/// What the pool beside the search box is saying, read off the DOM.
499///
500/// No text is stripped anywhere here. A pool chip's textContent is the tag and
501/// nothing else -- both marks are drawn by the theme -- so the state is read
502/// from the classes and `raw` can prove the text itself carries neither.
503/// `up` is now "something is filtering", not "the element is drawn": the pool
504/// is standing furniture and is drawn whenever a tag exists at all.
505const fstate = () => page.evaluate(() => {
506 const f = document.getElementById('diamond-filter');
507 const shown = !!f && f.style.display !== 'none' && getComputedStyle(f).display !== 'none';
508 const chips = shown ? [...f.querySelectorAll('.tagf-pool .tag-chip')] : [];
509 const inc = chips.filter(c => c.classList.contains('tag-inc'));
510 const exc = chips.filter(c => c.classList.contains('tag-no'));
511 const on = shown ? f.querySelector('.tag-mode-btn.on') : null;
512 return {
513 shown,
514 up: inc.length + exc.length > 0,
515 pool: chips.map(c => c.textContent),
516 inc: inc.map(c => c.textContent),
517 exc: exc.map(c => c.textContent),
518 raw: inc.concat(exc).map(c => c.textContent),
519 mode: on ? on.dataset.mode : null,
520 clear: shown && !!f.querySelector('.tag-clear-all'),
521 ctl: shown && !!f.querySelector('.tagf-ctl'),
522 };
523});
524const setMode = async (m) => {
525 await page.click(`#diamond-filter .tag-mode-btn[data-mode="${m}"]`, { force: true });
526 await page.waitForTimeout(400);
527};
528const railMarks = () => page.$$eval('.session-box-tags .tag-chip',
529 els => els.map(e => ({ t: e.textContent, on: e.classList.contains('tag-inc') })));
530
531// A chip cycles: off -> wanted -> refused -> off. Every leg is now walked in
532// the POOL, from one chip that never moves -- which is the point of it. On a
533// Diamond box the last leg is out of reach: refusing a tag takes every Diamond
534// carrying it off the rail, and the box chip goes with them.
535const cyc = [];
536await clickTag('person');
537cyc.push(await fstate());
538const wantMarks = await railMarks();
539check(wantMarks.some(m => m.t === 'person' && m.on) && wantMarks.every(m => m.t === 'person' || !m.on),
540 `the chip doing the filtering is marked in the rail, and only it: ${JSON.stringify(wantMarks)}`);
541check(stateOf(await poolState(), 'person') === 'inc',
542 'and the pool wears the same mark for the same tag, so the two surfaces agree');
543await poolClick('person');
544cyc.push(await fstate());
545const refused = await boxes();
546check(refused.length === 1 && refused[0] === 'Rust compiler notes',
547 `a second click refuses the tag, and its carriers leave the rail: ${JSON.stringify(refused)}`);
548// The property the pool exists for. Every Diamond carrying `person` has just
549// left the rail, so there is no box chip for it anywhere -- and the refusal is
550// still one click from being put down, because the pool is drawn from the store.
551check(stateOf(await poolState(), 'person') === 'exc'
552 && !(await railOf()).some(r => r.tags.includes('person')),
553 `a REFUSED tag stays in the pool though no Diamond showing it carries it: ${JSON.stringify(await poolState())}`);
554check(JSON.stringify(cyc[1].raw) === JSON.stringify(['person']),
555 `a refused chip's text is the bare tag and nothing else: ${JSON.stringify(cyc[1].raw)}`);
556const negation = await page.$eval('#diamond-filter .tag-no',
557 e => getComputedStyle(e, '::before').content).catch(() => '');
558check(negation.includes('¬'),
559 `the negation is drawn by the theme, not put in the chip's text: ${JSON.stringify(negation)}`);
560await shot(s, 'tags-dark-exclude');
561await poolClick('person');
562cyc.push(await fstate());
563check(!cyc[2].up && (await boxes()).length === 3,
564 `a third click in the pool puts the tag down again: ${JSON.stringify(cyc[2])}`);
565check(cyc.every(st => st.shown) && cyc.map(st => st.ctl).join() === 'true,true,false',
566 `the pool stands through all three legs; only the controls come and go: ${JSON.stringify(cyc.map(st => [st.shown, st.ctl]))}`);
567// The state a predicate would have to invent a meaning for -- one tag both
568// wanted and refused -- is one the cycle cannot produce: each leg MOVES the
569// tag, and moving is not copying. A step holding it in both lists would read
570// "inc+exc" below and fail, and so would a leg that never happened, so this one
571// line is both the order and the impossibility.
572const where = st => ['inc', 'exc'].filter(k => st[k].indexOf('person') !== -1).join('+') || 'off';
573check(JSON.stringify(cyc.map(where)) === JSON.stringify(['inc', 'exc', 'off']),
574 `the cycle is off -> wanted -> refused -> off, one list at a time: ${JSON.stringify(cyc.map(where))}`);
575
576// ── ALL and ANY ──────────────────────────────────────────────────
577await clickTag('person');
578check((await fstate()).mode === null,
579 'with one tag wanted there is no combining control: ALL and ANY would name the same list');
580// The clear IS offered at one tag now, where it used to wait for two. The chip
581// that used to be the one-click way off carried an × and so read "person×" to
582// everything that reads a chip; the pool's chip reads "person", and pays for it
583// with a second click round the cycle. This is what buys that back.
584check((await fstate()).clear === true,
585 'but the clear is, because a pool chip has no closer and the cycle takes two clicks to put one tag down');
586await clickTag('rust');
587const bothTags = await boxes();
588check(bothTags.length === 1 && bothTags[0] === 'Ship a CSV parser',
589 `two wanted tags default to ALL -- only the Diamond carrying both: ${JSON.stringify(bothTags)}`);
590const modeUp = await fstate();
591check(modeUp.mode === 'all' && modeUp.clear === true,
592 `a second tag raises the combining control on ALL, and the clear stays: ${JSON.stringify(modeUp)}`);
593await setMode('any');
594const anyBoxes = await boxes();
595check(anyBoxes.length === 2 && anyBoxes.includes('Mum birthday plan') && anyBoxes.includes('Ship a CSV parser'),
596 `ANY gives the union of the two instead: ${JSON.stringify(anyBoxes)}`);
597await shot(s, 'tags-dark-any');
598
599// The mode is a habit of reading and is kept; the two lists are a way of looking
600// at this page and are not. An account switch reloads, so this is that too.
601await page.reload({ waitUntil: 'domcontentloaded' });
602await signInAs(s, 'tags');
603await page.waitForTimeout(1500);
604const afterBoot = await fstate();
605check(!afterBoot.up && (await boxes()).length === 3,
606 `the wanted and refused lists do not survive a reload -- which is what an account switch is: ${JSON.stringify(afterBoot)}`);
607// The pool is not a filter state and does not reset with one: it is the
608// vocabulary, and it comes back with every chip off. This is the hard refresh
609// the user did, which found nothing under the search box at all.
610check(afterBoot.shown && !afterBoot.ctl
611 && JSON.stringify(afterBoot.pool) === JSON.stringify(['family', 'gifts', 'person', 'rust', 'urgent']),
612 `but the pool itself comes straight back on a hard reload, every chip off: ${JSON.stringify(afterBoot.pool)}`);
613// Open, because it was left open. The other half of the kept choice: closed
614// survived a reload above, and so does open -- it is one preference, not a
615// default that only bites one way.
616const dBoot = await disc();
617check(!!dBoot && dBoot.open === true,
618 `and comes back OPEN, because that is the way it was left: ${JSON.stringify(dBoot)}`);
619check(!(await page.isVisible('#diamond-tag-hint')),
620 'and the empty-pool hint stays away, because it keys off tags existing, not off filtering');
621await clickTag('person');
622await clickTag('rust');
623const kept = await fstate();
624check(kept.mode === 'any' && (await boxes()).length === 2,
625 `the ALL/ANY choice IS kept across the reload: ${JSON.stringify(kept)}`);
626await poolOff('rust');
627const oneLeft = await fstate();
628check(oneLeft.mode === null && oneLeft.clear === true && (await boxes()).length === 2,
629 `the combining control goes again when one tag is left, and the clear stays: ${JSON.stringify(oneLeft)}`);
630
631// ── A refusal beats a want ───────────────────────────────────────
632// "Mum birthday plan" carries `person`, which is wanted. Refusing `family`
633// still takes it off the rail: a tag you have said you do not want to see
634// cannot be talked round by one you do.
635await clickTag('rust'); // wanted: person, rust (ANY)
636await clickTag('family'); // wanted: + family, so Mum still shows
637await clickTag('family'); // refused
638const beaten = await boxes();
639check(beaten.length === 1 && beaten[0] === 'Ship a CSV parser',
640 `a refused tag hides its Diamond even though a wanted tag matches it: ${JSON.stringify(beaten)}`);
641const halves = await fstate();
642check(JSON.stringify(halves.inc.slice().sort()) === JSON.stringify(['person', 'rust'])
643 && JSON.stringify(halves.exc) === JSON.stringify(['family']),
644 `the pool shows both halves at once, so neither reason for hiding is silent: ${JSON.stringify(halves)}`);
645// One row, in one order, whatever the boolean is: the wanted, the refused and
646// the untouched sit alphabetically among each other rather than being gathered
647// into groups that move under a click.
648check(JSON.stringify(halves.pool) === JSON.stringify(['family', 'gifts', 'person', 'rust', 'urgent']),
649 `and does not reorder itself around them: ${JSON.stringify(halves.pool)}`);
650await shot(s, 'tags-dark-boolean');
651// ── Clear-all, and an honest empty rail ──────────────────────────
652await page.click('#diamond-filter .tag-clear-all', { force: true });
653await page.waitForTimeout(400);
654const cleared = await fstate();
655check(!cleared.up && cleared.shown && (await boxes()).length === 3,
656 `one click on the clear puts both lists down and leaves the pool standing: ${JSON.stringify(cleared)}`);
657// ALL over two tags no Diamond shares.
658await clickTag('person');
659await clickTag('rust');
660await clickTag('family');
661await poolOff('person');
662const anyTwo = await boxes();
663check(anyTwo.length === 2, `ANY over rust and family holds both their Diamonds: ${JSON.stringify(anyTwo)}`);
664await setMode('all');
665const impossible = await boxes();
666check(impossible.length === 0 && (await page.textContent('.diamond-list')).includes('No Diamonds match'),
667 `ALL over two tags no Diamond shares empties the rail and says why: ${JSON.stringify(impossible)}`);
668await page.click('#diamond-filter .tag-clear-all', { force: true });
669await page.waitForTimeout(400);
670check((await boxes()).length === 3, 'and the clear-all brings them back');
671
672// ── Removing a tag ───────────────────────────────────────────────
673await openTagEditor('Ship a CSV parser');
674await page.click('.tag-row:not(.tag-sug) .tag-x', { force: true });
675await page.waitForTimeout(600);
676check((await editorTags()).length === 1, `a tag can be removed: ${JSON.stringify(await editorTags())}`);
677await shot(s, 'tags-dark-editor');
678
679// ── The real normalisation, on the real wasm ─────────────────────
680// Straight at the boundary, because the editor's input caps length itself and
681// a restore can hand over whatever a hand-edited backup contains.
682const normCases = await wasm(async (app) => {
683 const id = JSON.parse(await app.list_diamonds()).find(r => r.name === 'Rust compiler notes').id;
684 const run = async (tags) => {
685 await app.set_tags(id, JSON.stringify(tags));
686 return JSON.parse(await app.list_diamonds()).find(r => r.id === id).tags;
687 };
688 const res = {
689 collapse: await run([' My BIG Project ']),
690 empties: await run(['ok', '', ' ', 'fine']),
691 dedupe: await run(['dup', 'DUP', ' dup ']),
692 charCap: await run(['é'.repeat(30)]),
693 tagCap: await run(Array.from({ length: 12 }, (_, i) => `t${i}`)),
694 };
695 // Malformed input must degrade, not throw.
696 try {
697 await app.set_tags(id, 'not json at all');
698 res.malformed = JSON.parse(await app.list_diamonds()).find(r => r.id === id).tags;
699 } catch (e) { res.malformed = `THREW: ${e}`; }
700 // What doImport hands over if a hand-edited backup has "tags":"person"
701 // (a bare string, not an array). It reaches set_tags as JSON `"person"`.
702 try {
703 await app.set_tags(id, JSON.stringify('person'));
704 res.stringNotArray = JSON.parse(await app.list_diamonds()).find(r => r.id === id).tags;
705 } catch (e) { res.stringNotArray = `THREW: ${e}`; }
706 res.cleared = await run([]);
707 return res;
708});
709check(JSON.stringify(normCases.collapse) === JSON.stringify(['my big project']),
710 `internal whitespace collapses and lowercases: ${JSON.stringify(normCases.collapse)}`);
711check(JSON.stringify(normCases.empties) === JSON.stringify(['ok', 'fine']),
712 `empty and whitespace-only tags are dropped: ${JSON.stringify(normCases.empties)}`);
713check(JSON.stringify(normCases.dedupe) === JSON.stringify(['dup']),
714 `tags differing only by case/padding dedupe: ${JSON.stringify(normCases.dedupe)}`);
715check(Array.isArray(normCases.charCap) && normCases.charCap.length === 1 && [...normCases.charCap[0]].length === 24,
716 `the 24 cap counts CHARACTERS not bytes: ${[...(normCases.charCap[0] || '')].length} chars of 'é' kept`);
717check(normCases.tagCap.length === 8, `at most 8 tags are kept: got ${normCases.tagCap.length}`);
718check(Array.isArray(normCases.malformed) && normCases.malformed.length === 0,
719 `malformed JSON degrades to no tags rather than throwing: ${JSON.stringify(normCases.malformed)}`);
720check(Array.isArray(normCases.stringNotArray),
721 `a bare string where an array was expected degrades rather than throwing: ${JSON.stringify(normCases.stringNotArray)}`);
722check(JSON.stringify(normCases.cleared) === JSON.stringify([]), 'an empty array clears every tag');
723
724// ── Themes ───────────────────────────────────────────────────────
725await page.reload({ waitUntil: 'domcontentloaded' });
726await signInAs(s, 'tags');
727await page.waitForTimeout(1200);
728await openTagEditor('Ship a CSV parser');
729await page.evaluate(() => window.DaimondTheme.set('light'));
730await page.waitForTimeout(600);
731await shot(s, 'tags-light-editor');
732await page.click('.crystal-act', { force: true });
733await page.waitForTimeout(400);
734await shot(s, 'tags-light-rail');
735const contrast = await page.$$eval('.session-box-tags .tag-chip', els => els.map(e => {
736 const cs = getComputedStyle(e);
737 return { t: e.textContent, fg: cs.color, bg: cs.backgroundColor };
738}));
739check(contrast.length > 0 && contrast.every(c => c.fg !== c.bg && c.bg !== 'rgba(0, 0, 0, 0)'),
740 `light theme resolves chip colours: ${JSON.stringify(contrast[0])}`);
741await page.evaluate(() => window.DaimondTheme.set('lollypop'));
742await page.waitForTimeout(600);
743await shot(s, 'tags-lollypop-rail');
744await page.evaluate(() => window.DaimondTheme.set('dark'));
745await page.waitForTimeout(400);
746
747// ── Honesty: a tag must never reach the model ────────────────────
748// Tag a Diamond with a string that could not occur naturally, steer it, then
749// read back everything the mock was actually sent.
750await openTagEditor('Mum birthday plan');
751await page.fill('.tag-input', 'zqxwmarker');
752await page.keyboard.press('Enter');
753await page.waitForTimeout(600);
754await page.click('.crystal-act', { force: true });
755await page.waitForTimeout(400);
756clearMockLog();
757await page.fill('#chat-input', 'Say hello.');
758await page.keyboard.press('Enter');
759for (let i = 0; i < 40; i++) { if (mockLog().length) break; await page.waitForTimeout(400); }
760await page.waitForTimeout(1000);
761const sent = JSON.stringify(mockLog());
762check(sent.length > 2, 'the model was actually called (so the next check can mean something)');
763check(!sent.includes('zqxwmarker'), 'no tag reached the model: not in the prompt, the tools or the transcript');
764const allCrystals = await wasm(async (app) => {
765 const rows = JSON.parse(await app.list_diamonds());
766 let all = '';
767 for (const r of rows) { try { all += await app.read_crystal_data(r.id); } catch (e) { /* none yet */ } }
768 return all;
769});
770check(!allCrystals.includes('zqxwmarker'), 'no tag leaked into any crystal');
771
772// ── The backup round trip ────────────────────────────────────────
773// Tags travel with a Diamond, or a restore silently drops the user's whole
774// filing system while looking like it worked.
775await page.click('#user-row');
776await page.waitForTimeout(400);
777const dl = page.waitForEvent('download', { timeout: 15000 });
778await page.click('button.admin-item:has-text("Export a backup")');
779const bpath = scratch('tags-backup.json');
780await (await dl).saveAs(bpath);
781const backup = JSON.parse(fs.readFileSync(bpath, 'utf8'));
782const bmum = (backup.diamonds || []).find(f => f.name === 'Mum birthday plan');
783check(bmum && Array.isArray(bmum.tags) && bmum.tags.includes('family'),
784 `the export carries tags: ${JSON.stringify(bmum && bmum.tags)}`);
785const brust = (backup.diamonds || []).find(f => f.name === 'Rust compiler notes');
786check(brust && Array.isArray(brust.tags) && brust.tags.length === 0,
787 `an untagged Diamond exports tags:[] not undefined: ${JSON.stringify(brust && brust.tags)}`);
788
789// Admin is still open from the export above, and its drawer sits over the rail --
790// including the New Diamond button the next section presses. A person would shut
791// it; the test has to as well, or it is asserting about a button nobody can reach.
792await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); });
793await page.waitForTimeout(400);
794
795// ── A create must never land behind a filter ─────────────────────
796// Proved through the search box in verify_diamonds until the box was removed.
797// The hazard is the filter's, not the box's: a Diamond made while the rail is
798// filtered to something it does not match is a Diamond the user cannot see and
799// did not lose.
800await clickTag('person');
801const beforeCreate = await boxes();
802check(!beforeCreate.includes('Rust compiler notes'),
803 `the rail is filtered to something the new Diamond will not match: ${JSON.stringify(beforeCreate)}`);
804await newDiamond('Made while filtered');
805await page.waitForTimeout(600);
806const afterCreate = await boxes();
807check(afterCreate.includes('Made while filtered'),
808 `a Diamond made while the rail is filtered is still visible: ${JSON.stringify(afterCreate)}`);
809check(!(await page.isVisible('#diamond-filter .tagf-ctl')),
810 'and the filter that would have hidden it is cleared');
811
812const errsA = errors(s).filter(e => !/502 \(Bad Gateway\)/.test(e));
813check(errsA.length === 0, `no console errors beyond the offline gateway: ${JSON.stringify(errsA.slice(0, 3))}`);
814await s.close();
815
816// A fresh profile: nothing of the first session is on this disk.
817const b = await open({ name: 'tagsB', defaults: false });
818await b.page.click('#user-row');
819await b.page.waitForTimeout(400);
820const chooser = b.page.waitForEvent('filechooser', { timeout: 15000 });
821await b.page.click('button.admin-item:has-text("Import a backup")');
822await (await chooser).setFiles(bpath);
823// A restore now confirms and reloads to bring every restored surface back
824// consistent; acknowledge it, let it reload, and unlock the fresh session.
825await b.page.waitForSelector('.dlg-ok', { timeout: 15000 });
826await b.page.click('.dlg-ok');
827await b.page.waitForSelector('#id-primary', { timeout: 15000 });
828await signInAs(b, 'tagsB');
829await b.page.waitForTimeout(800);
830const restored = await b.page.$$eval('.diamond-box', els => els.map(e => ({
831 name: (e.querySelector('.session-box-name') || {}).textContent,
832 tags: [...e.querySelectorAll('.session-box-tags .tag-chip')].map(c => c.textContent),
833 more: (e.querySelector('.tag-more') || {}).textContent || null,
834})));
835// A restore must bring each Diamond back ONCE. The workspace files carry the whole
836// of `diamonds/<id>/` (crystal, versions, log, meta with tags), so restoring them
837// reconstitutes each Diamond with its history; doImport no longer ALSO recreates it
838// from the summary, which used to yield two ids for one Diamond.
839check(restored.length === 3, `a restore brings each Diamond back exactly once -- got ${restored.length}, expected 3: ${JSON.stringify(restored.map(r => r.name))}`);
840
841// The thing this pass is actually proving: tags survive the round trip.
842const rmum = restored.filter(r => r.name === 'Mum birthday plan');
843check(rmum.length > 0 && rmum.every(r => r.tags.length === 5 && r.more === null),
844 `tags RESTORE from a backup into a fresh profile (5 tags, 5 chips, no "+N"): ${JSON.stringify(rmum[0])}`);
845const rrust = restored.filter(r => r.name === 'Rust compiler notes');
846check(rrust.length > 0 && rrust.every(r => r.tags.length === 0),
847 `an untagged Diamond restores untagged, with no chips: ${JSON.stringify(rrust[0])}`);
848await shot(b, 'tags-restored');
849
850// ── Deleting a tag that the filter is holding ────────────────────
851// Done on this profile because it destroys tags, and nothing follows it. A tag
852// deleted while WANTED would leave a filter no Diamond can satisfy; deleted
853// while REFUSED it would go on hiding Diamonds with no chip anywhere to click.
854// It has to leave both lists.
855const bp = b.page;
856const bBoxes = () => bp.$$eval('.diamond-box .session-box-name', els => els.map(e => e.textContent));
857const bClickTag = async (name) => {
858 await bp.$$eval('.session-box-tags .tag-chip', (els, n) => {
859 for (const e of els) if (e.textContent === n) { e.click(); return; }
860 }, name);
861 await bp.waitForTimeout(400);
862};
863const bState = () => bp.evaluate(() => {
864 const f = document.getElementById('diamond-filter');
865 const shown = !!f && f.style.display !== 'none' && getComputedStyle(f).display !== 'none';
866 const chips = shown ? [...f.querySelectorAll('.tagf-pool .tag-chip')] : [];
867 const inc = chips.filter(c => c.classList.contains('tag-inc'));
868 const exc = chips.filter(c => c.classList.contains('tag-no'));
869 return {
870 shown, pool: chips.map(c => c.textContent),
871 up: inc.length + exc.length > 0,
872 inc: inc.map(c => c.textContent),
873 exc: exc.map(c => c.textContent),
874 };
875});
876/// Delete a tag from the pool in the tag editor, and confirm it.
877const bKill = async (name) => {
878 await bp.evaluate((n) => {
879 const chip = [...document.querySelectorAll('.tag-sug .tag-chip')].find(e => e.textContent === n);
880 if (chip) chip.querySelector('.tag-kill').click();
881 }, name);
882 await bp.waitForSelector('.dlg-ok', { timeout: 10000 });
883 await bp.click('.dlg-ok');
884 await bp.waitForTimeout(1200);
885};
886// Unlocking left the Admin drawer over the rail, and it takes the clicks meant
887// for a Diamond box. Dismiss it exactly as a user would.
888const adminX = await bp.$('#admin-close');
889if (adminX && await adminX.isVisible()) { await adminX.click({ force: true }); await bp.waitForTimeout(400); }
890// A profile that has never been touched, holding five restored tags: the pool
891// is down and the row that names it is up. The first session proved the default
892// on a store it had built; this proves it on a disk it has never seen.
893const bFresh = await bp.evaluate(() => {
894 const b = document.querySelector('#diamond-filter .tagf-toggle');
895 return { row: !!b, open: b ? b.getAttribute('aria-expanded') === 'true' : null,
896 text: b ? b.textContent.trim() : null,
897 pool: !!document.querySelector('#diamond-filter .tagf-pool') };
898});
899check(bFresh.row && bFresh.open === false && !bFresh.pool && /\(\d+\)/.test(bFresh.text || ''),
900 `a fresh profile opens with the pool down and its count on the row: ${JSON.stringify(bFresh)}`);
901await setPoolOn(bp, true);
902// The count on the closed row is the promise; the chips are what it is a
903// promise OF. A number that did not match what opening gives you would be worse
904// than no number, and it is read from the store rather than from the chips.
905const bCount = await bp.$$eval('#diamond-filter .tagf-pool .tag-chip', els => els.length);
906check(bCount > 0 && +((bFresh.text || '').match(/\((\d+)\)/) || [])[1] === bCount,
907 `and the count it promised is the number of chips behind it: ${JSON.stringify(bFresh.text)} -> ${bCount} chips`);
908// The editor is opened FIRST, on "Ship a CSV parser": the pool only offers what
909// is not already on the Diamond being edited, and the filter below is built out
910// of tags that are on another one. The editor lives in the centre; the rail it
911// is read against stays where it is.
912const bIdx = (await bBoxes()).indexOf('Ship a CSV parser');
913await bp.$$eval('.diamond-box', (els, i) => els[i].click(), bIdx);
914await bp.waitForTimeout(500);
915for (const btn of await bp.$$('.crystal-act')) {
916 if ((await btn.textContent()).includes('Tags')) { await btn.click({ force: true }); break; }
917}
918await bp.waitForSelector('.tag-editor', { timeout: 5000 });
919await bClickTag('gifts'); // wanted: "Mum birthday plan" alone
920await bClickTag('family'); // wanted as well; Mum carries both
921await bClickTag('family'); // refused, so Mum goes and the rail empties
922const bSet = await bState();
923check(JSON.stringify(bSet.inc) === JSON.stringify(['gifts']) && JSON.stringify(bSet.exc) === JSON.stringify(['family']),
924 `a filter with one tag in each list, built by clicking: ${JSON.stringify(bSet)}`);
925check((await bBoxes()).length === 0, 'and it is currently hiding everything, which is the point');
926await bKill('family');
927const afterExcKill = await bState();
928check(afterExcKill.exc.length === 0 && JSON.stringify(afterExcKill.inc) === JSON.stringify(['gifts'])
929 && (await bBoxes()).length === 1,
930 `deleting a REFUSED tag takes it out of the filter, leaves the rest, and gives the Diamonds back: ${JSON.stringify(afterExcKill)}`);
931await bKill('gifts');
932const afterIncKill = await bState();
933check(!afterIncKill.up && (await bBoxes()).length === 3,
934 `deleting a WANTED tag clears the filter rather than emptying the rail: ${JSON.stringify(afterIncKill)}, ${JSON.stringify(await bBoxes())}`);
935check(!afterIncKill.pool.includes('family') && !afterIncKill.pool.includes('gifts'),
936 `and both deleted tags leave the pool, so nothing offers a filter that cannot match: ${JSON.stringify(afterIncKill.pool)}`);
937
938// ── The divider does not drift under the pool ────────────────────
939// The pool is furniture, and the Diamonds and Chats lists are cut out of what
940// the furniture leaves -- once, in pixels. Anything that changes the pool's
941// height has to make that cut again, or the height comes off the Chats list
942// alone and the divider walks up the rail a row at a time. Here the change is
943// live and small: a click raises the controls row under the pool.
944const shareOf = () => shareOn(bp);
945const beforeCtl = await shareOf();
946await bClickTag('person'); // raises .tagf-ctl: the furniture grows a row
947const withCtl = await shareOf();
948await bp.evaluate(() => { const c = document.querySelector('.tag-clear-all'); if (c) c.click(); });
949await bp.waitForTimeout(400);
950const afterCtl = await shareOf();
951check(withCtl.list + withCtl.sess < beforeCtl.list + beforeCtl.sess,
952 `raising the controls really does take height off the two lists: ${beforeCtl.list + beforeCtl.sess}px -> ${withCtl.list + withCtl.sess}px`);
953check(Math.abs(withCtl.share - beforeCtl.share) <= 0.03 && Math.abs(afterCtl.share - beforeCtl.share) <= 0.03,
954 `and the divider stays at its share rather than walking: ${beforeCtl.share} -> ${withCtl.share} -> ${afterCtl.share}`);
955check(beforeCtl.handle && afterCtl.handle, 'the divider itself is still there afterwards');
956
957// ── A vocabulary the rail cannot afford ──────────────────────────
958// Thirty-odd tags is a filing system, not a mistake, and the pool is furniture:
959// the rail's two lists live on what it leaves. Unbounded, it would push the
960// Diamonds off the bottom of the rail to show a row of chips for them.
961const beforeBulk = await shareOf();
962await bp.evaluate(async () => {
963 const m = await import('/pkg/oxedyne_daimond.js');
964 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
965 const words = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta'];
966 for (let d = 0; d < 4; d++) {
967 const id = await app.create_diamond('Bulk ' + d);
968 await app.set_tags(id, JSON.stringify(words.map(w => w + '-' + d))); // 8 is the per-Diamond cap
969 }
970});
971// A hard reload, because that is the state being tested: a store that already
972// has this much in it when the app opens.
973await bp.reload({ waitUntil: 'domcontentloaded' });
974await signInAs(b, 'tagsB');
975await bp.waitForTimeout(1800);
976const adminX2 = await bp.$('#admin-close');
977if (adminX2 && await adminX2.isVisible()) { await adminX2.click({ force: true }); await bp.waitForTimeout(400); }
978// Left open, so it comes up open -- on a store that now has a real vocabulary
979// in it, which is the case where the choice is worth most either way.
980const bBig = await bp.$eval('#diamond-filter .tagf-toggle',
981 el => ({ open: el.getAttribute('aria-expanded') === 'true', text: el.textContent.trim() })).catch(() => null);
982check(!!bBig && bBig.open && /\(3\d\)/.test(bBig.text),
983 `the kept choice survives the vocabulary arriving, and the count follows it: ${JSON.stringify(bBig)}`);
984const big = await bp.evaluate(() => {
985 const el = document.querySelector('#diamond-filter .tagf-pool');
986 const list = document.getElementById('diamond-list');
987 const sess = document.getElementById('session-list');
988 const cs = el ? getComputedStyle(el) : {};
989 const rail = document.getElementById('panel-rail').getBoundingClientRect();
990 return {
991 tags: el ? el.children.length : 0,
992 wrap: cs.flexWrap, over: cs.overflowY,
993 clientH: el ? el.clientHeight : 0,
994 scrollH: el ? el.scrollHeight : 0,
995 list: list ? Math.round(list.getBoundingClientRect().height) : 0,
996 sess: sess ? Math.round(sess.getBoundingClientRect().height) : 0,
997 // Nothing may hang out of the rail's width; a chip row that did would
998 // put a horizontal scrollbar under the whole column.
999 widest: el ? Math.max(0, ...[...el.children].map(c => Math.round(c.getBoundingClientRect().right))) : 0,
1000 railRight: Math.round(rail.right),
1001 // Is a row CUT by the cap? A cap landing on a row boundary would show
1002 // thirty tags as three tidy rows and nothing to say there are more.
1003 cut: el ? (() => {
1004 const box = el.getBoundingClientRect();
1005 return [...el.children].some(c => {
1006 const r = c.getBoundingClientRect();
1007 return r.top < box.bottom - 1 && r.bottom > box.bottom + 1;
1008 });
1009 })() : false,
1010 };
1011});
1012check(big.tags >= 30, `a store with a real vocabulary in it: ${big.tags} tags in the pool`);
1013check(big.wrap === 'wrap' && big.over === 'auto' && big.scrollH > big.clientH,
1014 `the pool wraps and then scrolls rather than growing: ${JSON.stringify(big)}`);
1015check(big.clientH <= 80,
1016 `and is capped at about three rows however many tags there are: ${big.clientH}px for ${big.tags} chips`);
1017// The complaint that started all this was a control that gave no sign it was
1018// there. A pool that ended flush on a row would be the same mistake one size
1019// down: thirty tags looking like nine, with nothing saying otherwise.
1020check(big.cut,
1021 `a full pool cuts a row rather than ending flush, so the reader can see there is more: ${big.clientH}px of ${big.scrollH}px`);
1022check(big.widest <= big.railRight,
1023 `no chip hangs out of the rail: widest right edge ${big.widest} vs rail ${big.railRight}`);
1024// The furniture moved, so the split below it has to have been cut again --
1025// which is what `fitTagFilter` is for. Both lists still have a usable height.
1026check(big.list >= 60 && big.sess >= 60,
1027 `the two lists still share the rail under a full pool: Diamonds ${big.list}px, Chats ${big.sess}px`);
1028check(Math.abs(big.list / (big.list + big.sess) - beforeBulk.share) <= 0.03,
1029 `and the divider is where it was before the vocabulary arrived: ${beforeBulk.share} -> ${(big.list / (big.list + big.sess)).toFixed(3)}`);
1030await shot(b, 'tags-pool-many');
1031// A pool that is scrolled must stay where it was put: it repaints on every
1032// click, and one that jumped back to the top would make the tags past the
1033// third row unusable.
1034const scrollKept = await bp.evaluate(async () => {
1035 const el = document.querySelector('.tagf-pool');
1036 el.scrollTop = el.scrollHeight;
1037 const before = el.scrollTop;
1038 [...el.querySelectorAll('.tag-chip')].pop().click();
1039 await new Promise(r => setTimeout(r, 350));
1040 const now = document.querySelector('.tagf-pool');
1041 return { before, after: now.scrollTop, lit: !!now.querySelector('.tag-inc') };
1042});
1043check(scrollKept.before > 0 && scrollKept.after === scrollKept.before && scrollKept.lit,
1044 `the pool holds its scroll across the repaint a click causes: ${JSON.stringify(scrollKept)}`);
1045await shot(b, 'tags-pool-many-scrolled');
1046await bp.evaluate(() => { const c = document.querySelector('.tag-clear-all'); if (c) c.click(); });
1047await bp.waitForTimeout(300);
1048
1049// ── Putting a full pool away gives the rail back ─────────────────
1050// The whole point of the disclosure, measured: with a real vocabulary in it the
1051// pool is three and a half rows of furniture, and closing it must hand every
1052// pixel of that to the two lists WITHOUT moving the divider between them.
1053const bigOpen = await shareOf();
1054await setPoolOn(bp, false);
1055const bigShut = await shareOf();
1056check(bigShut.list + bigShut.sess > bigOpen.list + bigOpen.sess + 40,
1057 `closing a full pool gives the rail back a real amount: ${bigOpen.list + bigOpen.sess}px -> ${bigShut.list + bigShut.sess}px`);
1058check(Math.abs(bigShut.share - bigOpen.share) <= 0.03,
1059 `and the divider does not drift for it: ${bigOpen.share} -> ${bigShut.share}`);
1060// Closed, with 36 tags filed, the whole filter is one row.
1061const bigRow = await bp.evaluate(() => {
1062 const f = document.getElementById('diamond-filter');
1063 return f ? Math.round(f.getBoundingClientRect().height) : 0;
1064});
1065check(bigRow > 0 && bigRow <= 26,
1066 `and 36 tags cost the rail one row instead of four: the filter is ${bigRow}px tall`);
1067await shot(b, 'tags-pool-many-collapsed');
1068// A filter built while it is closed still speaks, at this size too.
1069await bClickTag('alpha-0');
1070const bigAct = await bp.evaluate(() => {
1071 const row = document.querySelector('#diamond-filter .tagf-active');
1072 return { chips: row ? [...row.querySelectorAll('.tag-chip')].map(c => c.textContent) : null,
1073 pool: !!document.querySelector('#diamond-filter .tagf-pool'),
1074 ctl: !!document.querySelector('#diamond-filter .tagf-ctl') };
1075});
1076check(!!bigAct.chips && JSON.stringify(bigAct.chips) === JSON.stringify(['alpha-0'])
1077 && !bigAct.pool && bigAct.ctl,
1078 `one tag filtering out of 36 shows ONE chip, not 36: ${JSON.stringify(bigAct)}`);
1079const bigActShare = await shareOf();
1080check(Math.abs(bigActShare.share - bigOpen.share) <= 0.03,
1081 `and the divider holds when that row arrives: ${bigShut.share} -> ${bigActShare.share}`);
1082await shot(b, 'tags-pool-many-collapsed-active');
1083await bp.evaluate(() => { const c = document.querySelector('.tag-clear-all'); if (c) c.click(); });
1084await bp.waitForTimeout(300);
1085await setPoolOn(bp, true);
1086
1087// ── The phone drawer ─────────────────────────────────────────────
1088// The rail is a drawer there, 380px of a 390px screen, and the pool is the
1089// first thing under the search box on it.
1090await bp.setViewportSize({ width: 390, height: 844 });
1091await bp.waitForTimeout(600);
1092await bp.evaluate(() => document.getElementById('drawer-btn').click());
1093await bp.waitForTimeout(700);
1094// Back to the top of the pool, so the shot below is the state a thumb opens on.
1095await bp.evaluate(() => { const el = document.querySelector('.tagf-pool'); if (el) el.scrollTop = 0; });
1096await bp.waitForTimeout(200);
1097const phone = await bp.evaluate(() => {
1098 const el = document.querySelector('#diamond-filter .tagf-pool');
1099 if (!el) return null;
1100 const r = el.getBoundingClientRect();
1101 const cs = getComputedStyle(el);
1102 return {
1103 up: cs.display !== 'none' && r.height > 0,
1104 h: Math.round(r.height), right: Math.round(r.right), vw: window.innerWidth,
1105 scrollH: el.scrollHeight,
1106 // The drawer scrolls as one column; the pool must not drag it along
1107 // when a thumb runs off the end of the chips.
1108 contain: cs.overscrollBehaviorY,
1109 chips: el.children.length,
1110 };
1111});
1112check(!!phone && phone.up && phone.chips >= 30,
1113 `the pool is on the phone drawer too: ${JSON.stringify(phone)}`);
1114check(!!phone && phone.h <= 80 && phone.right <= phone.vw,
1115 `capped there as well, and inside the screen: ${JSON.stringify(phone)}`);
1116check(!!phone && phone.contain === 'contain',
1117 `and its own scroll does not become the drawer's: ${JSON.stringify(phone && phone.contain)}`);
1118await shot(b, 'tags-pool-phone');
1119// And the row it collapses to, on the same screen: the drawer is where the rent
1120// is dearest -- a phone shows a handful of Diamonds at a time, and four rows of
1121// chips is most of what a thumb came for.
1122await setPoolOn(bp, false);
1123const phoneShut = await bp.evaluate(() => {
1124 const b2 = document.querySelector('#diamond-filter .tagf-toggle');
1125 const f = document.getElementById('diamond-filter');
1126 if (!b2 || !f) return null;
1127 const r = b2.getBoundingClientRect(), fr = f.getBoundingClientRect();
1128 return { text: b2.textContent.trim(), h: Math.round(fr.height),
1129 w: Math.round(r.width), right: Math.round(r.right), vw: window.innerWidth,
1130 pool: !!document.querySelector('#diamond-filter .tagf-pool') };
1131});
1132check(!!phoneShut && !phoneShut.pool && phoneShut.h <= 26 && phoneShut.right <= phoneShut.vw,
1133 `the phone drawer collapses to one row inside the screen: ${JSON.stringify(phoneShut)}`);
1134await shot(b, 'tags-pool-phone-collapsed');
1135await bp.setViewportSize({ width: 1400, height: 900 });
1136await bp.waitForTimeout(500);
1137
1138const errsB = errors(b).filter(e => !/502 \(Bad Gateway\)/.test(e));
1139check(errsB.length === 0, `no console errors on the restoring session: ${JSON.stringify(errsB.slice(0, 3))}`);
1140await b.close();
1141
1142console.log(out.join('\n'));
1143if (known.length) console.log(`\nKNOWN, NOT THIS CHANGE:\n - ${known.join('\n - ')}`);
1144console.log(bad === 0 ? `\nALL ${out.length} CHECKS PASSED` : `\n${bad} of ${out.length} FAILED`);
1145process.exit(bad === 0 ? 0 : 1);