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; +}