From 2720d0e5ffed1d671a947b282dbb8a1dc754d420 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 17:52:01 +0000 Subject: [PATCH] feat(site): explain each operation in plain words on the page The page said what Covering array and Strength were called but not what they meant. Each operation (count, list and its limit, dead values, covering array and its strength, one combination by index, and explain) now has a short paragraph under its heading or beside its field, and the controls name it in aria-describedby, so it shows on screen and a screen reader reads it when the control is focused. No tooltip is involved. The texts live in one module, filled into placeholders in index.html when the page starts. A test reads index.html itself and checks that every control of an operation names a description that exists and has text, that every description has a paragraph, and that the covering array and strength wording says what it was asked to. --- site/index.html | 28 +++++++++------ site/src/help.ts | 30 ++++++++++++++++ site/src/help.unit.test.ts | 70 ++++++++++++++++++++++++++++++++++++++ site/src/main.ts | 2 ++ site/src/raw.d.ts | 6 ++++ site/src/style.css | 5 +++ 6 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 site/src/help.ts create mode 100644 site/src/help.unit.test.ts create mode 100644 site/src/raw.d.ts diff --git a/site/index.html b/site/index.html index 2fd26d4..5f03eb3 100644 --- a/site/index.html +++ b/site/index.html @@ -72,44 +72,52 @@

Run

Count

- +

+

List

+

- - + +
+

Dead values

- +

+

Covering array

+

- - + +
+

One combination

+

- - + +
-
+

+
Pick a value for each dimension
- +
diff --git a/site/src/help.ts b/site/src/help.ts new file mode 100644 index 0000000..88086aa --- /dev/null +++ b/site/src/help.ts @@ -0,0 +1,30 @@ +import { keysOf } from "./exhaustive"; +import { MAX_LIST_LIMIT } from "./limits"; + +/** The parts of the Run section that carry a plain-language description. Each is the `data-help` of a paragraph in index.html, which the controls it describes name in `aria-describedby`. */ +export type HelpKey = "count" | "list" | "list-limit" | "diagnose" | "covering" | "strength" | "one" | "explain"; + +/** + * Every description the page shows, in one place. They are short, in plain words, and the first thing a reader meets under an operation's heading or beside its field, so none depends on a tooltip, and a screen reader reads each when its control gets focus. + */ +export const HELP: Readonly> = { + count: "Counts the combinations that satisfy every rule, without listing them. It works on models far too large to list.", + list: "Lists valid combinations in order, the first dimension changing slowest. It stops at the number you set, so a very large model cannot hang the page.", + "list-limit": `The most combinations to show, from 0 to ${String(MAX_LIST_LIMIT)}. When there are more, the page says so.`, + diagnose: "A dead value is one that no valid combination uses, because the rules rule it out wherever it could appear. Finding them shows where a rule is stricter than you meant.", + covering: + "A covering array is a short list of valid combinations that tests every interaction you care about without listing them all. Each row is a valid combination. The result is checked against the full set of valid combinations, when the model is small enough to do that, and the page reports whether it passed.", + strength: + "How many options at a time must be covered together. 1 means every single value appears in some row. 2, pairwise and the default, means every pair of values from any two dimensions appears together in some row. 3 means every triple, and so on. Combinations the rules make impossible are not required.", + one: "Every valid combination has a fixed position, its index, counting from 0 in the order that List shows. Enter an index to see the combination at that position.", + explain: "Pick a value for each dimension to describe one combination. Explain says whether it is valid and gives its index, or names each reason it is ruled out.", +}; + +/** Writes each description into the paragraph made for it. Throws for a paragraph that names a description there is none of, which is a defect in the page. */ +export function fillHelp(root: ParentNode): void { + for (const paragraph of root.querySelectorAll("[data-help]")) { + const key = keysOf(HELP).find((known) => known === paragraph.dataset["help"]); + if (key === undefined) throw new Error(`no description for ${String(paragraph.dataset["help"])}`); + paragraph.textContent = HELP[key]; + } +} diff --git a/site/src/help.unit.test.ts b/site/src/help.unit.test.ts new file mode 100644 index 0000000..ef2f1ae --- /dev/null +++ b/site/src/help.unit.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; + +import indexHtml from "../index.html?raw"; +import { fillHelp, HELP } from "./help"; +import { keysOf } from "./exhaustive"; + +/** The page as served, with the descriptions filled in as the page does when it starts. */ +function page(): Document { + const document = new DOMParser().parseFromString(indexHtml, "text/html"); + fillHelp(document); + + return document; +} + +/** The controls of an operation that a person can focus: buttons, fields and the fieldset of pickers, but not the menus the page draws into that fieldset later. */ +function controlsOf(document: Document): HTMLElement[] { + return [...document.querySelectorAll(".operation button, .operation input, .operation select:not(.pickers *), .operation fieldset")]; +} + +describe("operation descriptions", () => { + it("has a non-empty paragraph on the page for every description, and a description for every paragraph", () => { + const document = page(); + const paragraphs = [...document.querySelectorAll("[data-help]")]; + + expect(paragraphs.map((paragraph) => String(paragraph.dataset["help"])).toSorted((left, right) => left.localeCompare(right))).toEqual(keysOf(HELP).toSorted((left, right) => left.localeCompare(right))); + for (const paragraph of paragraphs) expect(paragraph.textContent.trim().length, `the paragraph for ${paragraph.dataset["help"] ?? ""}`).toBeGreaterThan(0); + }); + + it("names, on every control of an operation, a description that exists and is not empty", () => { + const document = page(); + const controls = controlsOf(document); + + expect(controls.length).toBeGreaterThan(0); + for (const control of controls) { + const names = (control.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((name) => name !== ""); + expect(names.length, `${control.id} names a description`).toBeGreaterThan(0); + for (const name of names) expect((document.getElementById(name)?.textContent ?? "").trim().length, `${control.id} is described by #${name}`).toBeGreaterThan(0); + } + }); + + it("puts each description inside the operation it describes, as a paragraph that is not hidden", () => { + const document = page(); + + for (const paragraph of document.querySelectorAll("[data-help]")) { + expect(paragraph.closest(".operation"), `${paragraph.id} is in an operation`).not.toBeNull(); + expect(paragraph.tagName).toBe("P"); + expect(paragraph.closest("[hidden]")).toBeNull(); + } + }); + + it("explains the covering array and its strength in the plain words asked for", () => { + expect(HELP.covering).toContain("short list of valid combinations"); + expect(HELP.covering).toContain("without listing them all"); + expect(HELP.covering).toContain("checked"); + expect(HELP.strength).toContain("How many options at a time must be covered together"); + expect(HELP.strength).toContain("1 means every single value"); + expect(HELP.strength).toContain("pairwise"); + expect(HELP.strength).toContain("default"); + expect(HELP.strength).toContain("3 means every triple"); + expect(HELP.strength).toContain("rules make impossible are not required"); + }); + + it("is written in plain sentences: no spaced double hyphen, no dash, no doubled spaces, and each ends with a full stop", () => { + for (const [key, text] of Object.entries(HELP)) { + expect(text, key).not.toMatch(/ -- |—|–| {2}/); + expect(text.endsWith("."), key).toBe(true); + } + }); +}); diff --git a/site/src/main.ts b/site/src/main.ts index 8aa7b43..d47a303 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -9,6 +9,7 @@ import { unreachable } from "./exhaustive"; import type { SharedState } from "./fragment"; import { shareUrl } from "./fragment"; import { ModelForm } from "./form"; +import { fillHelp } from "./help"; import type { JsonObject } from "./json"; import { DEFAULT_LIST_LIMIT, DEFAULT_STRENGTH, MAX_LIST_LIMIT } from "./limits"; import type { DimensionSummary, Failure, Request, Response, Result, Row, Verification } from "./protocol"; @@ -570,6 +571,7 @@ async function loadFromLocation(): Promise { } function start(): void { + fillHelp(document); for (const [position, example] of EXAMPLES.entries()) exampleSelect.append(new Option(example.name, String(position))); listLimit.value = String(DEFAULT_LIST_LIMIT); listLimit.max = String(MAX_LIST_LIMIT); diff --git a/site/src/raw.d.ts b/site/src/raw.d.ts new file mode 100644 index 0000000..192e8c8 --- /dev/null +++ b/site/src/raw.d.ts @@ -0,0 +1,6 @@ +/** Vite's `?raw` import of a file as its text, which the help test uses to read index.html. */ +declare module "*?raw" { + const text: string; + + export default text; +} diff --git a/site/src/style.css b/site/src/style.css index 30426c6..0daf563 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -548,3 +548,8 @@ fieldset legend { .entry > * { min-width: 0; } + +.operation .hint { + margin: 4px 0 8px; + font-size: 0.9rem; +}