Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,44 +72,52 @@ <h2 id="run-heading">Run</h2>

<div class="operation">
<h3>Count</h3>
<button type="button" class="op" id="count">Count combinations</button>
<p class="hint" id="count-help" data-help="count"></p>
<button type="button" class="op" id="count" aria-describedby="count-help">Count combinations</button>
<div id="count-result" class="result" aria-live="polite"></div>
</div>

<div class="operation">
<h3>List</h3>
<p class="hint" id="list-help" data-help="list"></p>
<div class="row">
<label>Show at most <input type="number" id="list-limit" min="0" step="1" inputmode="numeric" /></label>
<button type="button" class="op" id="list">List combinations</button>
<label>Show at most <input type="number" id="list-limit" min="0" step="1" inputmode="numeric" aria-describedby="list-limit-help" /></label>
<button type="button" class="op" id="list" aria-describedby="list-help">List combinations</button>
</div>
<p class="hint" id="list-limit-help" data-help="list-limit"></p>
<div id="list-result" class="result" aria-live="polite"></div>
</div>

<div class="operation">
<h3>Dead values</h3>
<button type="button" class="op" id="diagnose">Find dead values</button>
<p class="hint" id="diagnose-help" data-help="diagnose"></p>
<button type="button" class="op" id="diagnose" aria-describedby="diagnose-help">Find dead values</button>
<div id="diagnose-result" class="result" aria-live="polite"></div>
</div>

<div class="operation">
<h3>Covering array</h3>
<p class="hint" id="covering-help" data-help="covering"></p>
<div class="row">
<label>Strength <input type="number" id="strength" min="1" step="1" inputmode="numeric" /></label>
<button type="button" class="op" id="covering">Build covering array</button>
<label>Strength <input type="number" id="strength" min="1" step="1" inputmode="numeric" aria-describedby="strength-help" /></label>
<button type="button" class="op" id="covering" aria-describedby="covering-help">Build covering array</button>
</div>
<p class="hint" id="strength-help" data-help="strength"></p>
<div id="covering-result" class="result" aria-live="polite"></div>
</div>

<div class="operation">
<h3>One combination</h3>
<p class="hint" id="one-help" data-help="one"></p>
<div class="row">
<label>Index <input type="number" id="index" min="0" step="1" inputmode="numeric" /></label>
<button type="button" class="op" id="at">Show combination at index</button>
<label>Index <input type="number" id="index" min="0" step="1" inputmode="numeric" aria-describedby="one-help" /></label>
<button type="button" class="op" id="at" aria-describedby="one-help">Show combination at index</button>
</div>
<fieldset id="pickers" class="pickers">
<p class="hint" id="explain-help" data-help="explain"></p>
<fieldset id="pickers" class="pickers" aria-describedby="explain-help">
<legend>Pick a value for each dimension</legend>
</fieldset>
<button type="button" class="op" id="explain">Explain and find index</button>
<button type="button" class="op" id="explain" aria-describedby="explain-help">Explain and find index</button>
<div id="combination-result" class="result" aria-live="polite"></div>
</div>
</section>
Expand Down
30 changes: 30 additions & 0 deletions site/src/help.ts
Original file line number Diff line number Diff line change
@@ -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<Record<HelpKey, string>> = {
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<HTMLElement>("[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];
}
}
70 changes: 70 additions & 0 deletions site/src/help.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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<HTMLElement>("[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<HTMLElement>("[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);
}
});
});
2 changes: 2 additions & 0 deletions site/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -570,6 +571,7 @@ async function loadFromLocation(): Promise<boolean> {
}

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);
Expand Down
6 changes: 6 additions & 0 deletions site/src/raw.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions site/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,8 @@ fieldset legend {
.entry > * {
min-width: 0;
}

.operation .hint {
margin: 4px 0 8px;
font-size: 0.9rem;
}
Loading