c4bcca06ef1a06c434c9136a79459f1512cd0606 braney Tue Sep 8 07:41:02 2026 -0700 docent: expect: can assert the color a track's row was drawn in, and a test for #36212 A bug about color leaves the page identical -- same rows, same height, same item names, same tooltips -- so every check expect: had was blind to it. `color:` reads the pixels instead: it names the color a row is mostly drawn in (`is:`), or one it must not be (`not:`), with `part: label` for the center label rather than the items, `at:`/`frac:`/`x:` for one item rather than the whole row, and a list form so one step can state a whole color matrix and a failure name every row that came out wrong. hgTracks draws the whole view into one png and shows each row as a CSS-offset slice of it, so a row's pixels are that slice drawn into a canvas at its offset. The clipping box is the img's own div.sliceDiv, not the table cell: the center label and the data are two slices inside one td_data_, and measuring the cell runs the canvas past the end of this row and into the next track's, which reads that track's color as part of this one. The side labels are a separate png and are never included, since "what color is this row" must not be answered by the label text. White is background; everything else counts, black included, because a track with no color of its own draws black items. is:/not: take r,g,b or #rrggbb and no CSS color names, because trackDb's `color 0,255,0` is not CSS green. tests/colorchecks covers all of it and tests/colorchecks.xfail aims all six forms wrong in one step, so the failure has to name all six. tests/regress/rm36212.xfail is the bug it was written for: a stanza that sets both `itemRgb on` and `color` draws its items in the color setting instead of in the file's own RGB column, because bedItemRgb() (hg/cgilib/bedCart.c) tests for the presence of `color` before it tests for an explicit `itemRgb on`, so the explicit setting is never reached. It is an xfail because the bug is live on the RR, on beta and on genome-test. It is also the first script in that directory that has been watched both to fail on a build with the bug and to pass on a build with the fix -- the three-line reorder built into parked #36212 and the same script pointed at that port. The fixture is ~/public_html/docentFixtures/itemRgbHub/, four tracks over four copies of one bed9 file whose items all carry a pure blue itemRgb column. refs #36212, refs #37892 diff --git src/hg/utils/docent/README.md src/hg/utils/docent/README.md index 89eb35bc4b5..d10b562e248 100644 --- src/hg/utils/docent/README.md +++ src/hg/utils/docent/README.md @@ -151,31 +151,31 @@ | `mouseover: {track: dbSnp155Common, item: rs28406051, pin: true}` | `pin: true` **records** that tooltip (its text + position) so a later `pinShot:` can show several mouseovers open together in one figure. Nothing is added to the recorded page, so the **mp4 is unaffected** (it still shows only the transient native tooltip). Set `pinMouseovers: true` at the top of the file to record every mouseover by default (`pin: false` opts one out). Records accumulate within a view and are cleared on the next nav. | | `pinShot: all_tips` | Write `.png` with **all recorded (pinned) tooltips open at once**, each with a **cursor drawn at the point it was raised from** — so the figure says which feature every tooltip belongs to instead of leaving the reader to infer it from the anchor. Rendered on a throwaway page that shares the session (same cart/view) — never on the recorded page — so it never appears in the mp4. Consumes the recorded set (clears it). Place it after the `mouseover` steps whose tooltips you want shown together, before any nav/zoom. Map form `pinShot: {name: all_tips, cursors: false}` drops the pointers. | | `click: {track: mane, item: "NM_000546.6"}` | **Click a track item** and follow its own map-box link, which is how the hgc details page is reached — a raw mouse click on the data area is swallowed by hgTracks' drag-select handler. Addressed the same way `mouseover:` is: by identity (`item:`/`title:`/`value:`) or by **position** (`at:`/`frac:`/`x:`), which takes the item box nearest that point. Position is the only way in for a track whose items cannot be named at all — every GIAB Problematic Regions subtrack is `type bigBed 3`, so hgTracks writes an empty `i=` into the hgc href and gives every box the same title, and `click: {track: alldifficultregions, frac: 0.5}` is then the way to open one. A bare string is a plain CSS selector click instead (`click: 'a:has-text("placed on its chromosome")'`), for a link on a page that has no track image. | | `click: {track: mane, item: "NM_000546.6", raw: true}` | **Press the mouse where a user presses it** and let the page answer, instead of following the item's link. A different gesture, not a slower route to the same page: hgTracks answers a real item click with an ajax **dialog** (`popUpHgcOrHgGene.hgc`), and a whole class of bug lives in that dialog rather than on the hgc page -- hgTracks hanging on the SECOND click of the same item (#36805). Following the href never opens a dialog, so it can never see one. With no item name, `raw:` is a **bare point on the row** (`frac:`/`at:`/`x:`), which is the only way to click a row that carries no hgc map boxes at all: a click on the **ruler** (#27113). Docent waits for whichever of the three answers arrives -- a navigation, a dialog, or a new image in place -- so it needs no sleep. Note that jQuery UI **hides** a dialog on close rather than removing it, so assert `has: "#hgcDialog:visible"`, not `has: "#hgcDialog"`. | | `convert: {to: GCA_018466845.2, quicklift: true, hideDefaults: true}` | View→Convert, then **type the target into the page's own "Search for target genome" bar** and click the suggestion (`search:` overrides what is typed, `pick:` disambiguates the menu); the Assembly dropdown is checked afterwards and only opened by hand if the search didn't land there. QuickLift on, **re-checks Hide-defaults** (it reverts when the Assembly menu reloads), Submit. `to:` accepts an accession or a label fragment (`2257.pat`, matched against the dropdown text). A bare string is `to:`, so `convert: hs1` is a plain coordinate convert to hs1 -- `quicklift: true` is never implied. | | `convert: {to: ..., shot: convert_filled}` | `shot:` inside `convert:` captures the Convert page, which no other verb can reach. A bare name is the **filled-in page just before Submit**. The map form names up to three moments: `shot: {opened: a, filled: b, result: c}` — `opened` as the page comes up, `filled` ready to Submit, `result` the conversion-result page (whose coordinate link `open: lift` clicks). These are viewport stills, so they show the page from the top. | | `hub: https://example.org/hub.txt` | **Quick, silent** attach of a track hub by URL (`hgTracks?hubUrl=...`): connects the hub so its tracks are available at their hub-declared visibility. Follow with `track:` to turn specific ones on. Map form `hub: {url: ..., db: hg38, position: chr7:...}` overrides the db/position (default: current `db` + last position). | | `addHub: https://example.org/hub.txt` | **Demonstrates the attach through the UI** (for the figure/video): opens My Data → Track Hubs, clicks the **Connected Hubs** tab, types the URL into the box, and clicks **Add Hub** — cursor glides and the URL is typed on screen. Then, on the "Hub Connect Successful" page, it **clicks the `Open:` link for `db`** so the demo ends on the browser with the hub loaded. Map form `addHub: {url: ..., db: hg38, shot: loaded}` sets which assembly to open and captures the still on that tracks view. Use `hub:` instead when you just need the hub attached without showing the steps. | | `addCustomTrack: ` | **Demonstrates loading a custom track via the UI**: opens My Data → Custom Tracks, types the track data (or a data URL) into the paste box, clicks **Submit**, then clicks through to the browser (**Go to first annotation**). Bare string is the data or URL; map form `addCustomTrack: {data: "track ...\nchr7 ...", db: hg38, goto: first, shot: loaded}` (use `url:` for a data URL, `goto: current` to land on **Return to current position** instead). Data is inserted literally (tabs/newlines preserved). In YAML, a multi-line track uses a block scalar: `addCustomTrack: |` then the indented lines. | | `addPublicHub: GTEx` | **Demonstrates connecting a PUBLIC hub via the UI**: opens My Data → Track Hubs, the **Public Hubs** tab, types the search terms, clicks **Search Public Hubs**, then clicks **Connect** on the matching hub row, and finally **clicks the `Open:` link for `db`** to land on the browser. Bare string is the search term (also used to match the row). A search usually returns several hubs, so use the map form `addPublicHub: {search: "GTEx", match: "GTEx Analysis Hub", db: hg38, shot: loaded}` to pick the exact hub by a substring of its row text (`match:` defaults to `search:`) and the assembly to open. If no row matches it **won't connect** (warns and stops) rather than pick the wrong hub. | | `drag: chr7:155,805,900-155,806,950` | Emulates the **Shift+drag-select** gesture: the cursor sweeps across the selection (a visible selection box is drawn) and the browser's own drag-select dialog is raised, then a button is clicked. The argument is one genomic region, `chrom:start-end`; a bare range **zooms**. For any other action, or to pass other keys, put the region under `range:` and quote it — unquoted commas split a `{..}` flow map: `drag: {range: "chr7:155,805,900-155,806,950", shot: dragselect, then: highlight}`. Endpoints that are not genomic coordinates are given as a fraction (`fromFrac:`/`toFrac:`) or raw px (`fromX:`/`toX:`) instead. Optional `track:` picks the row the box is drawn over; default is the top of the image. `shot: dragselect` captures the open dialog. `then:` = `zoom` (default → **Zoom In**) \| `highlight` (→ Single Highlight) \| `cancel` (Escape, view unchanged). (A real button-held drag would just pan, so the dialog is driven directly.) | | `open: lift` | Click the returned coordinate link → the lifted view. | | `zoom: out` / `zoom: in` | One zoom step (2×). | | `montage: {name: figure1, shots: [source, lifted]}` | Compose stills already written this run into **one multi-panel PNG**, which is what a journal wants for a figure with parts (A), (B), and so on. Panels are stacked in order and lettered automatically; `labels: [Before, After]` overrides the letters, `labels: false` drops them, `direction: horizontal` puts them side by side, and `gap:` / `labelSize:` tune the spacing and lettering. Composed at deviceScaleFactor 1 with every panel at its **natural pixel size**, so the composite is pixel-for-pixel its inputs: a `make hires` montage is print resolution because the panels were, not because anything was upscaled. Panels narrower than the widest are left-aligned and padded, never stretched. A named shot that was never taken is warned about and skipped. Put it last, after the `shot:`/`pinShot:` steps it names. | | `loadSession: https://example.org/settings.txt` | Start from a **saved state** instead of a clean cart, so one tour can begin where another ended and a bug report that arrives as a session link becomes a starting position. Four forms: a **settings file by URL** (as above), a **share link** (`loadSession: https://genome.ucsc.edu/s/Braney/hg38`), a **named session** (`loadSession: {user: Braney, name: hg38}`), or a **local file** written by an earlier `session:` (`loadSession: {file: saved}` → `sessions//saved.txt`, sent up through hgSession's own upload form, so the project's sessions need not be published at all). Quick and silent, like `hub:` — this is setup, not something the tour demonstrates; add `shot:` to capture where it lands. Whatever the form, the load is issued against `target:` — see **Sessions** for why a share link is not simply followed. | -| `expect: {rows: [ruler, mane]}` | **The one verb that can fail a run.** Everything else renders happily whatever it is handed, so a wrong figure is written over a right one and only an eye catches it. State the expectation instead and the run stops, non-zero, at the step that broke it. Checks, any combination: `rows:` (these were drawn — plain names, matched by suffix so a lifted `hub__mane` counts), `exact: true` (…and nothing else), `ordered: true` (…and in that order, top to bottom), `noRows:` (these were not), `height: 2000` (the still is no taller than that in pixels; `"<1200"`, `">=300"` for another comparison), `tip: "mismatch A->C"` (the tooltip now up says this), `text:` / `noText:` (the page does / does not contain this — `noText: "Too Long"` catches the Apache 414 that renders as a perfectly good page), `url:` / `noUrl:` (the current address does / does not contain this — which CGI a click reached, or what a form put in the query string; `noUrl: "%E2%80%8B"` is the only way to see that a search term's zero-width space was stripped, since it is invisible in the page), `has:` / `noHas:` (a CSS selector matches / matches nothing — for a bug whose whole signature is WHERE something sits, like a center label attached to the wrong row: same rows, same height, same pixels. Reach for these last, since an assertion on hgTracks' own ids breaks easily for reasons that are not bugs). A failure names every check that failed **and the rows actually drawn**. `warn: true` downgrades it to a warning for a check worth logging but not worth stopping a build over. | +| `expect: {rows: [ruler, mane]}` | **The one verb that can fail a run.** Everything else renders happily whatever it is handed, so a wrong figure is written over a right one and only an eye catches it. State the expectation instead and the run stops, non-zero, at the step that broke it. Checks, any combination: `rows:` (these were drawn — plain names, matched by suffix so a lifted `hub__mane` counts), `exact: true` (…and nothing else), `ordered: true` (…and in that order, top to bottom), `noRows:` (these were not), `height: 2000` (the still is no taller than that in pixels; `"<1200"`, `">=300"` for another comparison), `tip: "mismatch A->C"` (the tooltip now up says this), `text:` / `noText:` (the page does / does not contain this — `noText: "Too Long"` catches the Apache 414 that renders as a perfectly good page), `url:` / `noUrl:` (the current address does / does not contain this — which CGI a click reached, or what a form put in the query string; `noUrl: "%E2%80%8B"` is the only way to see that a search term's zero-width space was stripped, since it is invisible in the page), `color:` (the color a track's row is actually **drawn** in -- `{track: crm4, is: "0,0,255"}`, or `not:` for one it must not be; `part: label` asks about the center label instead of the items, `at:`/`frac:`/`x:` about one item instead of the whole row, and a **list** states several rows in one step. The only check that reads the IMAGE, for a bug that leaves the page identical -- same rows, same height, same names, same tooltips), `has:` / `noHas:` (a CSS selector matches / matches nothing — for a bug whose whole signature is WHERE something sits, like a center label attached to the wrong row: same rows, same height, same pixels. Reach for these last, since an assertion on hgTracks' own ids breaks easily for reasons that are not bugs). A failure names every check that failed **and the rows actually drawn**. `warn: true` downgrades it to a warning for a check worth logging but not worth stopping a build over. | | `session: source` | Write `sessions//.txt`: the **whole cart at this step**, in the format hgSession's "save settings to a local file" produces, so anyone can load it and get this exact view. Every track's visibility, the attached hubs, the custom tracks, the window. Off the video and off the page — it is fetched over the tour's own cookies, so the tour is not disturbed and nothing appears in the mp4. With `sessionUrlBase:` set at the top of the file, the run also prints the ready-made load URL. See **Sessions**. | | `shot: source` | Write `.png` **and** pause the video here. On a tracks page the still is the track image (`#imgTbl`), plus any open tooltip/dialog. On any other page (an hgc detail page, an external page a link led to) it is the **viewport only — the top of the page**, never the whole scrolling document. | Escape hatches for anything the verbs don't cover: `goto: `, `click: `, `hover: `, `wait: `, `sleep: `. ## Sessions A `shot:` gives a picture of the view. A `session:` gives the view itself: ```yaml sessionUrlBase: https://hgwdev-you.gi.ucsc.edu/~you/docent/sessions steps: - hide: all - track: {clinvar: pack} @@ -247,30 +247,31 @@ way of the screen run's files rather than overwriting them. ## Expectations Every other verb renders happily whatever it is handed. A superTrack that came up whole makes an image 7,581 px tall, a subtrack that never hid stays in the figure, a pinned tooltip grabs the neighbouring item, an Apache 414 arrives as a perfectly valid page saying *Request-URI Too Long*. All of those have shipped at least once, all were caught by eye, and all of them are mechanically checkable. `expect:` is the only verb that looks at any of it: ```yaml - track: {varsInPubs: hideKids, pubtator: pack} - expect: {rows: [ruler, mane, pubtator], exact: true, height: 2000, noText: "Too Long"} - mouseover: {track: quickLiftChain, item: "4.3.157828209.157828210", pin: true} - expect: {tip: "mismatch A->C"} + - expect: {color: {track: crm4, is: "0,0,255", not: "0,255,0"}} ``` A failure prints every check that failed **and the rows that were actually drawn**, then exits non-zero at that step, so `make` fails instead of writing a wrong figure over a right one and the stills that would have followed are never taken: ``` step 5 (expect) failed: rows not drawn: clinvarMain; image is 69px, wanted <50 drawn: ruler, mane ``` Notes: - **Other verbs can still fail a run, they just do not check anything.** A verb throws when it cannot do what it was told: `mouseover:` cannot find the item, `loadSession:` @@ -286,30 +287,55 @@ nothing else", so neither of them can fail because the rows came out in the wrong order. When the order is the thing under test — a quickLift target that should read in source order, a superTrack whose members should follow their priorities — add `ordered: true` and the named rows have to appear top to bottom in the order given. Rows that were not drawn at all are reported once, by `rows:`, and skipped by the order check rather than failing twice. A failure names the first pair that is inverted: step 12 (expect) failed: rows out of order: mane should be drawn above gtexGeneV8 drawn: ruler, hub_274823_quickLiftChain, hub_274823_gtexGeneV8, hub_274823_mane - **`height:` is in still pixels**, which is the CSS height times the device pixel ratio — the number someone means by "7,581 px tall". A print run makes it k times bigger, so a ceiling that passes at 1x will fail at 3x. Scale it with `scale:` or leave the check to the screen run. - **A bare number is a ceiling** (`height: 2000` means no taller than 2000), which is the check anyone actually wants. `"<1200"`, `">=300"` and `"=850"` are there when it is not. +- **`color:` reads the pixels, and it is the only check that does.** A bug about color + changes nothing else: #36212 draws the items of a track that sets both `itemRgb on` and + `color` from the color setting rather than from the file's own RGB column, and the rows, + the height, the item names and the tooltips are identical either way. It names the color + a row is mostly drawn in: + + expect: {color: {track: crm4, is: "0,0,255"}} # the items are blue + expect: {color: {track: crm4, part: label, is: "0,255,0"}} # the center label green + expect: {color: {track: crm4, at: "chr1:1004500"}} # one item, not the row + expect: {color: [{track: crm3, is: "0,255,0"}, {track: crm4, is: "0,0,255"}]} + + White is background and is not counted; everything else is, black included, since a + track with no color of its own draws black items. `is:` and `not:` take `r,g,b` or + `#rrggbb` and match within `tolerance:` (default 8) -- **not** a CSS color name, because + trackDb's `color 0,255,0` is not CSS `green`. `at:`/`frac:`/`x:` narrow the sample to a + column `wide:` px across (default 5) at that position, which is how one item in a row of + several colors gets asked about on its own. A row that drew nothing at all fails saying + so rather than reporting a color, and the failure always lists the top three colors with + their share of the row: + + step 8 (expect) failed: crm4 is drawn 0,255,0, wanted 0,0,255 + -- the row holds 0,255,0 (98%), 127,255,127 (1%), 190,255,190 (1%) + + The side labels are a separate image and are never included: "what color is this row" + must not be answered by the label text. - **`warn: true`** logs the failure and carries on, for a check worth recording but not worth stopping a build over. ## Speed A full run is a real browser against a real server, and most of its wall clock is the pacing that makes the *video* watchable, not work. Measured on BP1 (15 steps, 4 stills): **64 s**, of which ~40 s is dwells, cursor animation and dropdown theatrics, ~23 s is page loads, and ~1 s is the mp4 transcode. So when you are iterating on the **figures**, skip the video: make FAST=1 BP1 # or DOCENT_FAST=1, or `fast: true` in the script FAST drops the dwells (`pace`, `shotHold`, mouseover holds), moves the cursor in one jump,