4265f36510c9498df6d7b0a8ff9e5251350c133c
braney
  Sun Sep 20 14:34:09 2026 -0700
docent: read a graph's tooltip, and noTip: for a bare number, refs #37892

hgTracks has two tooltips and their ids differ only in the case of one letter.
hg/js/utils.js hangs an ITEM's tooltip off `#mouseoverContainer`: the text of a
map box's title, an rsID or a gene name.  A WIGGLE reports itself the other way.
The `mouseOver` module in hg/js/hgTracks.js writes the value under the cursor
into `#mouseOverText`, read from the per-pixel spans hgTracks leaves in a trash
.json.  A track drawn as a coverage graph has no map boxes at all, so its numbers
exist only in the second one, and `tip:` on such a track read an empty string and
then waited out its timeout.  Every reader of the live tooltip now takes whichever
is up: `tip:`, the `mouseover:` waits, `pinShot:` and the shot clip.

Two more from the same mistake.  `mouseover: {value: ...}` is documented for
wiggles and had never matched anything on any track: it looked in
`window.mapData.spans[...]` for a member called `value`, which is hg/js/mouseOver.js,
an older copy of the module the page does not load, and the member there is `v`
as well.  It now reads `mouseOver.items[...]` and places the cursor from
`td_data_<track>`, which is what hgTracks.js itself compares the cursor against.
And asking by value now skips the map boxes entirely, because a track's own center
label is titled "Click to alter the display density of <track>": a probe for the
value 3 hovered the center label of a track called rm38253stairs before any span
was looked at.

`noTip:` is new.  `tip:` is a substring test, which is right for an item, whose
tooltip is markup and whose tail can render differently from the title it came
from.  It is wrong for a graph, whose tooltip is the number and nothing else:
`tip: "1"` is also satisfied by "1.5" and by "13", and a mean computed with the
wrong divisor is exactly the fraction that would slip through.  Naming the other
digits and the decimal point leaves one number.  This is the prefix trap #38279
hit with two messages sharing a head, in a place with no closing paren to carry
the delimiter.

diff --git src/hg/utils/docent/README.md src/hg/utils/docent/README.md
index e68b1a27ba8..b741b51f634 100644
--- src/hg/utils/docent/README.md
+++ src/hg/utils/docent/README.md
@@ -141,49 +141,49 @@
 `steps:` is an ordered list. Each item is a bare verb or a one-key map.
 
 | Step | What it does |
 |------|--------------|
 | `gateway` | Open hgGateway for `db`. |
 | `go` | Click **GO** with the default position (no typing). |
 | `go: chr7:155799529-155812871` | Go straight to a position in hgTracks. |
 | `goShow: BRCA1` | **Demonstrates the position change through the UI** (vs. `go:` which navs there directly): the cursor glides to the **position box** and the term is **typed on screen**, then the page finishes the job — **Search** on hgTracks, the arrow on hgGateway — so one verb covers either page. Takes a **gene name** or a **position** (or anything else the box accepts: HGVS, an accession). Bare string, or map form `goShow: {gene: SHH, shot: source}` / `goShow: {position: "chr7:155,799,529-155,812,871", shot: source}` — quote coords in the map form (unquoted commas split a `{..}` flow map). |
 | `goShow: {gene: TP53, pick: "NR_176326"}` | A **gene name** goes through the browser's own **suggestion menu**, the way a user does it: Docent waits out the hgSuggest ajax, then clicks the matching row, so you land on the gene (not the search-results page) and the video shows the dropdown. Default match is the exact gene symbol; `pick:` selects a specific row by a substring of its text when the term is ambiguous (a `pick:` that matches nothing warns and falls back to the plain gene match). If nothing ever matches, the typed term is submitted as-is — that may land on the **search-results page**, which is legal, and a `click:` can take it from there. |
 | `hide: all` | Click **Hide all**. |
 | `track: {mane: pack}` | Set a track's visibility (`hide/dense/squish/pack/full`). The mouse visibly glides to that track's control dropdown, opens it, and picks the mode (then the state is applied). |
 | `track: {dbSnp155Common: pack}` | A subtrack by name: trackDb's view and composite above it come along, so this is the whole "common dbSNP" config. (`dbSnp155` is a *different*, off-by-default subtrack — all variants — not an alias for the composite.) |
 | `track: {clinvar: pack, clinvarCnv: hide}` | Composite: the container's mode reaches its subtracks, so name only the deviations. Docent adds the containers above and the `_sel` checkbox from trackDb — see **Track names come from trackDb**. |
 | `track: {varsInPubs: hideKids, pubtator: pack}` | **superTrack: show one member only.** `hideKids` is not a visibility — it means *hide everything under this container*, so the child named alongside it is left the only one drawn. A superTrack needs this because, unlike a composite, its own mode does **not** reach its children: each comes up at its own trackDb visibility, so `{varsInPubs: show}` draws all eight of its members and an earlier `hide: all` does not stick. The expansion skips any child the step names itself and is sent in a round of its own **after** the rest — a subtrack hide travelling in the same request as its container can be dropped by the cart (#37953), so the container goes on first and the hides follow. Works on a composite or view too, where it deselects (`_sel=0`) rather than just hiding. `hideKids` alone (no child named) simply empties the container. The expansion stops at the first container that propagates its own visibility, so a composite under the superTrack is hidden as a unit rather than enumerated: `{cCREs: hideKids}` on hg38 sends two variables, not the 1701 that naming all 850 subtracks of the ENCODE4 Core Collection would take (which overran Apache's request-line limit and returned a 414 page in place of the view). |
 | `track: {anyTrackName: dense}` | Any track by its **trackDb** name. A name trackDb doesn't have (hub, custom track, quickLift target) is sent literally as `name=mode`. |
-| `mouseover: {track: dbSnp155Common, item: rs28406051}` | Hover a **named** item to raise its tooltip. `item:` matches the item's map box (its `&i=<name>` HREF or its TITLE) and hovers that rectangle's center — so it lands on the **correct row** even when items are stacked, which a bare coordinate can't do. It then waits for **that item's own tooltip**, not merely for a tooltip: the browser shows one 500 ms after `mouseenter` and hides it 500 ms after `mouseleave` (`hg/js/utils.js`), so the cursor gliding in crosses other items and one of THEIR tooltips can still be on screen on arrival — which is how an Alignment Differences mismatch got pinned as its neighbour's "identical". The expected text comes from the item's own map box, rendered the way the tooltip renders it (the attribute holds markup and undecoded entities), and `DOCENT_ROWS=1` warns if it never appears. `title: "SHH"` matches the TITLE text only; `value: "..."` matches a JSON `mouseOver` span (wig/dense tracks). |
+| `mouseover: {track: dbSnp155Common, item: rs28406051}` | Hover a **named** item to raise its tooltip. `item:` matches the item's map box (its `&i=<name>` HREF or its TITLE) and hovers that rectangle's center — so it lands on the **correct row** even when items are stacked, which a bare coordinate can't do. It then waits for **that item's own tooltip**, not merely for a tooltip: the browser shows one 500 ms after `mouseenter` and hides it 500 ms after `mouseleave` (`hg/js/utils.js`), so the cursor gliding in crosses other items and one of THEIR tooltips can still be on screen on arrival — which is how an Alignment Differences mismatch got pinned as its neighbour's "identical". The expected text comes from the item's own map box, rendered the way the tooltip renders it (the attribute holds markup and undecoded entities), and `DOCENT_ROWS=1` warns if it never appears. `title: "SHH"` matches the TITLE text only; `value: "..."` matches a wiggle's per-pixel `mouseOver` span, which is the only way a track drawn as a graph -- a wiggle, or a bigBed/VCF/BAM in density mode -- can be addressed at all, since it has no per-item map box. It reads the spans `hg/js/hgTracks.js` fetches from a trash .json and skips the map-box search entirely, because a track's own center label is titled "Click to alter the display density of &lt;track&gt;" and would match any name the digits appear in. |
 | `mouseover: {track: mane, at: chr7:155805900}` | Hover by **position** when you don't need a specific item: genomic coord (`at:`), a fraction across the view (`frac: 0.5`), or a raw pixel (`x: 400`); the y is forced to the middle of that track's row (cannot disambiguate stacked items). Optional on any `mouseover`: `hold: 2.5` (seconds to dwell) and `shot: tip_mane` (capture the image **plus** the tooltip in one still). |
 | `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 `<name>.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: <text-or-url>` | **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. |
 | `login` | **Sign in through hgLogin**, for the pages that refuse a visitor who is not logged in -- hgCollection above all (`hgCollection.c` doMiddle: *You must be logged in to edit collections*), and the saving half of hgSession. The login cookie is validated against a salted hash (`login.cookieSalt`, `hg/lib/wikiLink.c`), so there is no cookie to hand the browser: a script that needs one of those pages has to sign in the way a person does. **The step takes no credentials and cannot be given any.** They are read from `~/.docentLogin` (override with `DOCENT_LOGIN_FILE`), **one section per hgcentral database** -- an account is a row in `gbMembers` in one of them, so that is the key, not the server and not the sandbox:<br><br>`[hgcentraltest]`<br>`user=docentTest`<br>`password=...`<br><br>genome-test, hgwdev, every `hgwdev-<name>` sandbox and every ticket park read hgcentraltest, so one account covers all of them; hgwbeta reads hgcentralbeta and the RR reads hgcentral. Which central a server reads is **read from its hg.conf**, not assumed from the host, because a sandbox can say so for itself -- 45 of the personal confs on hgwdev set `central.db=hgcentraltest` and two do not. A server whose conf is on another machine falls back to a small table (the RR, the two mirrors, hgwbeta), and `[default]` catches the rest. A run redirected with `DOCENT_TARGET` looks up the server it is really driving. The file is refused unless it is mode 0600 -- the rule `hg/lib/hgConfig.c` applies to `hg.conf`. `DOCENT_LOGIN_USER` + `DOCENT_LOGIN_PASSWORD` override the file for one run. Nothing prints a password. A wrong password fails the step rather than carrying on logged out, because hgLogin answers one by drawing the same form again, which is a perfectly good page. Map form `login: {shot: signed_in}`. `make preflight` reports the account and the central it resolved, so a missing password is caught before the browser starts. |
 | `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/<base>/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_<n>_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, one string or a **list** of them — `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 in the page's TREE, 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), `box:` (where an element sits on the SCREEN — `{sel: "#topRightLinks", inside: "#main-menu-whole"}`, plus `clear:` for what it must not overlap and `height:`/`width:` for its own size. The only check that reads a bounding box, for a bug that leaves every selector matching and every word of the page in place). 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_<n>_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, as a SUBSTRING -- an item's tooltip is markup and its tail can render differently from the title it came from), `noTip:` (...and does not say this, one string or a list. It is the delimiter `tip:` has no other way to carry: a wiggle's tooltip is a bare number, so `tip: "1"` is also satisfied by "1.5" and by "13", and naming the other digits and the decimal point leaves one number), `text:` / `noText:` (the page does / does not contain this, one string or a **list** of them — `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 in the page's TREE, 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), `box:` (where an element sits on the SCREEN — `{sel: "#topRightLinks", inside: "#main-menu-whole"}`, plus `clear:` for what it must not overlap and `height:`/`width:` for its own size. The only check that reads a bounding box, for a bug that leaves every selector matching and every word of the page in place). 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/<base>/<name>.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 `<name>.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: <url>`, `click: <sel>`,
 `hover: <sel>`, `wait: <sel>`, `sleep: <ms>`.
 
 ## 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}
@@ -255,30 +255,32 @@
   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"}
+  - mouseover: {track: clinvarCnv, at: "chr1:1050000"}
+  - expect: {tip: "3", noTip: [".", "0", "1", "2"]}
   - expect: {color: {track: crm4, is: "0,0,255", not: "0,255,0"}}
   - expect: {box: {sel: "#topRightLinks", inside: "#main-menu-whole"}}
 ```
 
 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: