074123ba2fb70164a7aa364f7c8baf21d70f55a5
braney
  Thu Aug 27 09:04:05 2026 -0700
docent: let expect: check the order rows were drawn in, refs #37892

rows: has been a set test, and exact: true only added "and nothing else",
so no assertion in the language could fail because the rows came back in
the wrong order. ordered: true adds that check: the named rows have to
appear top to bottom in the order given. A row that was not drawn at all
is reported once, by rows:, and skipped here rather than failing twice.
A failure names the first pair that is inverted.

The case that prompted it is #38032, a quickLift target returning its
tracks in the order they were lifted rather than the order they have on
the source. A tour of that bug passed every check on the broken build.

Two tests: ordered asserts the hg38 order, and ordered.xfail names the
same two rows backwards and has to fail, since a flag that cannot fail is
only a second copy of the set test beside it.

diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index e48111f3c4f..c8604e635d1 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -1170,30 +1170,31 @@
         await nav(`/cgi-bin/hgTracks?hgS_doLoadUrl=submit&hgS_loadUrlName=${enc(String(from))}`);
     }
     await page.waitForSelector('#imgTbl').catch(() => {});
     await captureState();
     if (o.shot) await shot(o.shot);
   }
   // The only verb that can fail a run. Every other verb renders happily whatever it is
   // handed: a superTrack that came up whole and made an image 7,581 px tall, a subtrack
   // that never hid, a pinned tooltip that grabbed the neighbouring item, an Apache 414
   // page where the view should be. All of those shipped once and all were caught by eye.
   // Stating the expectation instead stops the run, non-zero, at the step that broke it --
   // `make` then fails rather than writing a wrong figure over a right one.
   //
   //   expect: {rows: [ruler, mane]}         these rows were drawn
   //   expect: {rows: [ruler, mane], exact: true}   ... and nothing else
+  //   expect: {rows: [ruler, mane], ordered: true} ... in that order, top to bottom
   //   expect: {noRows: [clinvarCnv]}        this row was not
   //   expect: {height: 2000}                the still is no taller than this ("<1200" etc.)
   //   expect: {tip: "mismatch A->C"}        the tooltip now up says this
   //   expect: {text: "...", noText: "..."}  the page does / does not contain this
   //
   // `warn: true` downgrades a failure to a warning, for a check worth logging but not worth
   // stopping a build over.
   async function expectState(arg) {
     const o = (arg && typeof arg === 'object') ? arg : { text: arg };
     const seen = await page.evaluate(() => {
       const im = document.getElementById('imgTbl');
       const tip = document.getElementById('mouseoverContainer');
       const up = tip && tip.offsetWidth > 0 && getComputedStyle(tip).display !== 'none'
         && getComputedStyle(tip).visibility !== 'hidden';
       return {
@@ -1207,30 +1208,46 @@
     // the device pixel ratio -- which is what someone means by "7,581 px tall", and what a
     // print run makes k times bigger.
     const height = Math.round(seen.cssHeight * SCALE);
     // A hub track's row id carries a per-run hub_<n>_ prefix (a quickLift target's rows all
     // do), so match the plain name by suffix, the way `mouseover:` resolves a track.
     const drawn = w => seen.rows.some(r => r === w || r.endsWith('_' + w));
     const list = v => v == null ? [] : (Array.isArray(v) ? v : [v]).map(String);
     const bad = [];
     const want = list(o.rows);
     const missing = want.filter(w => !drawn(w));
     if (missing.length) bad.push(`rows not drawn: ${missing.join(', ')}`);
     if (o.exact && want.length) {
       const extra = seen.rows.filter(r => !want.some(w => r === w || r.endsWith('_' + w)));
       if (extra.length) bad.push(`unexpected rows: ${extra.join(', ')}`);
     }
+    // `ordered: true` makes rows: a check on the ORDER they were drawn in as well as on
+    // which ones were. seen.rows is in document order and hgTracks draws top to bottom,
+    // so the named rows have to appear at increasing positions. A row that was not drawn
+    // at all is already reported above, and is skipped here rather than producing a
+    // second failure saying the same thing. Without this a set test cannot fail for a
+    // wrong order, which is the whole of a bug like a quickLift target coming back in
+    // request order rather than source order (#38032).
+    if (o.ordered) {
+      const seq = want.map(w => ({ w, at: seen.rows.findIndex(r => r === w || r.endsWith('_' + w)) }))
+                      .filter(e => e.at >= 0);
+      for (let i = 1; i < seq.length; i++)
+        if (seq[i].at < seq[i - 1].at) {
+          bad.push(`rows out of order: ${seq[i - 1].w} should be drawn above ${seq[i].w}`);
+          break;
+        }
+    }
     const banned = list(o.noRows).filter(w => drawn(w));
     if (banned.length) bad.push(`rows that should not be drawn: ${banned.join(', ')}`);
     if (o.height != null) {
       // A bare number is a ceiling, which is the check anyone actually wants.
       const m = /^\s*(<=|>=|<|>|=)?\s*(\d+)\s*$/.exec(String(o.height));
       if (!m) bad.push(`height: cannot read "${o.height}"`);
       else {
         const n = Number(m[2]), op = m[1] || '<=';
         const ok = op === '<' ? height < n : op === '>' ? height > n
           : op === '>=' ? height >= n : op === '=' ? height === n : height <= n;
         if (!ok) bad.push(`image is ${height}px, wanted ${op}${n}`);
       }
     }
     if (o.tip != null && !seen.tip.includes(String(o.tip)))
       bad.push(seen.tip ? `tooltip says "${seen.tip}", wanted "${o.tip}"`