# JS scenario harnesses — custom-select "All" behaviour

Drives the real `public/assets/js/select2-custom.js` against a jsdom document, so
the select-all state machine is checked rather than reasoned about. Covers both
render paths: virtualised (>500 options) and plain (<=500).

Not wired into `composer test` — phpunit doesn't run node, and jsdom is not a
project dependency. Run manually:

```bash
cd /tmp && npm install jsdom --no-save
node <repo>/html/tests/js/select-all-small.test.js

# The virtualised harness MUST be run at both pool sizes. PIN_CAP inside the
# widget is 200: above it the "pin selected to top" pass is skipped and selected
# rows are rendered by appendChunk() instead — a different code path, and the one
# real advertisers land on (adv 15325 has 653 campaigns, adv 13421 has 17,967).
BETA_COUNT=7   node <repo>/html/tests/js/select-all-virtualised.test.js   # pinning ON
BETA_COUNT=650 node <repo>/html/tests/js/select-all-virtualised.test.js   # pinning OFF

node <repo>/html/tests/js/select-all-duplicate-rows.test.js
node <repo>/html/tests/js/breakdown-table-column-count.test.js
node <repo>/html/tests/js/column-visibility.test.js
```

Both exit non-zero on failure, so they can be dropped into CI once jsdom is a
declared dev dependency.

## What they pin down

- Default: "All" ticked with every option actually selected, and a whole-pool
  selection posts **nothing** (an absent field is what every consumer reads as
  "no restriction", and posting ~18k ids would exceed `max_input_vars`).
- "All" is scoped to the active search: ticking it selects only the matches, and
  unticking it removes only the matches.
- Clearing the search preserves the selection and drops the "All" tick once the
  list widens beyond what is selected.
- Unticking one option turns "All" off and keeps the rest.
- The select-all row is never hidden by a search that doesn't match its own text.
- An empty selection reads `data-empty-text`, not the "All ..." placeholder.
- **The rendered checkboxes agree with the selection** (`renderedMismatches()`).

## Why that last one exists

The first version of these harnesses asserted only on the internal `selectedSet`
and on what the form posts. Both were correct while every checkbox rendered
unticked, so the suite was green against a widget that looked completely broken:
`appendChunk()` hardcoded `checked = false`, which only matters once the selection
exceeds `PIN_CAP` — a branch a 10-option pool never reaches.

Two lessons worth keeping: assert on what the user sees, not just on internal
state, and run size-dependent code at a size that crosses its own thresholds.

## select-all-duplicate-rows.test.js

Separate file because it needs a shape the others do not: pool > `CHUNK_SIZE`
(1000) so a later chunk exists to scroll into, and a selection <= `PIN_CAP` (200)
so pinning is on. It pins a campaign sitting past the first chunk, unticks it,
then scrolls — which used to render that campaign a second time, because
`appendChunk()` decided what to skip from the *live* selection while the pinned
rows were a snapshot. Both now read the same snapshot (`pinnedValues`).

Verified to catch the regression: run against
`git show HEAD:html/public/assets/js/select2-custom.js` it reports
`[{"value":"1400","rendered":2}]`.

## breakdown-table-column-count.test.js

For every remaining Breakdown tab: the header count declared in
`Views/breakdown-view/index.php` vs the number of cells `buildTableRows()` in
`breakdown-view.js` actually emits — for the body rows **and** for the aggregate
("Total") `<tfoot>` row it returns alongside them. They are declared in two
different languages in two different files, and nothing else checks that they
agree — when they drift the table renders with its columns shifted or truncated,
and a footer whose cell count disagrees with the columns also makes DataTables
warn on init.

It additionally pins how many footer cells carry a value. `TOTALS_COLUMNS` in
`breakdown-view.js` declares a column index per summed metric per tab (4 on the
12/13-column tabs, 3 on Landing Pages which has no Conversions column, 1 on
Conversion Type); an index that drifts out of range drops that metric's total
with only a `console.warn` to show for it.

Exists because AP-132 broke exactly this. Removing the Locations and Search Terms
row builders was done by deleting the source span between two comment markers, and
the Landing Pages and Conversion Type builders sat inside that span. Both tabs then
fell through to the generic 12-column path against 8- and 3-column headers.

Run against the commit that shipped the bug it reports:
`landing-pages-tab 8 vs 12`, `conversion-type-tab 3 vs 12`.

## column-visibility.test.js

Drives the real `public/assets/js/column-visibility.js` against a synthetic
four-column table that has the shape every grid using the widget has: a locked
row-identity column at position 1, a `data-sort`-only column (the fallback key
path), a header whose label is wrapped in a sort `<form>`, and a `<tfoot>`
totals row with one `<th>` per column.

What it pins:

- **Hiding a column emits a `tfoot` rule as well as `thead`/`tbody`.**
  `components/table/totals_row.php` emits one `<th>` per column and deliberately
  no `colspan`, so a footer cell that outlives its header slides every Total one
  column to the right — wrong values under the right headers, which reads as a
  data bug rather than a display bug. `applyCss` shipped without this for a
  while; it was masked because Campaign Management's totals row starts `d-none`.
- **No rule is ever emitted for `:nth-child(1)`.** The `<td colspan="N">`
  placeholder rows (`components/table/table.php`'s `showEmptyRow`, and
  `breakdown-view.js`) carry no `.dataTables_empty` class for the selector to
  exclude, and they only ever sit at position 1 — so "always lock the first
  column" is a requirement of this approach, not a style choice.
- **Every table column has a row in the menu, in table order.** Locked columns
  are listed ticked-and-disabled rather than omitted. Omitting them made the menu
  disagree with the grid behind it -- "Day of Week is right there, why isn't it
  listed?" -- and a disabled row answers that where an absent row cannot. A
  forced `change` on a locked row must not reach `hiddenSet`; the guard is that
  locked rows carry `[data-colvis-locked]` and no `[data-colvis-option]`.
- **`data-col-label` beats the `textContent` fallback.** The dashboard's sort
  `<form>` puts a `↓`/`⇅` glyph inside the `<th>`, which the fallback would show
  in the dropdown.
- **The storage namespace.** `window.adcColvisNs` wins when set, falls back to
  `adcMngtUser` (a cached layout served against new JS must not reset a mngt
  user's saved columns), and an advertiser namespace does not read the mngt
  bucket — which is the reason `auth_colvis_ns()` exists.
