// The help overlay: the rows are `improvise_protocol::help`'s, and the keys in
// them came off the keymap (improvise-ktv1). PR #61 deleted the spec that said
// so and nothing replaced it (improvise-78v6); what replaced it said only that
// a row EXISTED, which a row drawn blank satisfies (improvise-igxp).
import { test, expect } from "@playwright/test";
async function openHelp(page) {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await page.locator("#grid").focus();
await page.keyboard.press("?");
await expect(page.locator("#help")).toBeVisible();
await expect(page.locator(".help-tab.active")).toHaveText("Welcome");
}
/// The `
` read as the pairs it is: a `dt` and the `dd` that follows it,
/// which is the association the page encodes in document order and nowhere
/// else. Reading `dt` and `dd` as two lists would zip prose rows (which have
/// no `dd`) against the wrong descriptions and still look tidy.
const pairs = (page) =>
page.locator(".help-body").evaluate((dl) =>
[...dl.children]
.filter((el) => el.tagName === "DT")
.map((dt) => ({
kind: dt.className,
keys: dt.textContent,
description:
dt.nextElementSibling?.tagName === "DD"
? dt.nextElementSibling.textContent
: null,
})),
);
test.describe("the help overlay draws the shared content", () => {
// improvise-igxp. Every assertion here is about TEXT: `toBeVisible` on a
// `dt` passes against an empty one, which is the whole failure this exists
// to catch — a page drawing the right NUMBER of blank rows.
test("? opens help, and its rows carry the keys and the words, not empty frames", async ({
page,
}) => {
await openHelp(page);
// The five pages are `help::pages()`; the page spells no title.
await expect(page.locator(".help-tab")).toHaveText([
"Welcome",
"Navigation",
"Editing",
"Panels & Views",
"Commands",
]);
// Welcome is prose end to end: every row is a `dt` with words in it and
// NO `dd` beside it — the rule `paintHelp` encodes as "a prose row has no
// description and spans both columns". An empty `dd` per prose row would
// leave a blank grid cell, and nothing but a count can see that.
const welcome = await pairs(page);
expect(welcome.length).toBeGreaterThan(20);
expect(welcome.every((row) => row.description === null)).toBe(true);
expect(welcome.filter((row) => row.kind === "help-banner")).toEqual([
{ kind: "help-banner", keys: " improvise", description: null },
]);
expect(welcome.filter((row) => row.kind === "help-heading").map((r) => r.keys)).toEqual([
"Quick start",
"Core concepts",
]);
// A blank row is the ONE row that is legitimately empty, so the
// "nothing is blank" rule below has to be stated over the others.
expect(
welcome.some((row) => row.kind === "help-blank" && row.keys === ""),
).toBe(true);
expect(
welcome.filter((row) => row.kind !== "help-blank").every((row) => row.keys.trim() !== ""),
).toBe(true);
await page.keyboard.press("l");
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
const navigation = await pairs(page);
// The keys rows, which are the ones with a `dd`. `keys_for` drops a row
// whose command is unbound, so a row that is HERE claims a real binding.
const keyed = navigation.filter((row) => row.kind === "help-keys");
expect(keyed.length).toBeGreaterThan(10);
// The injection improvise-igxp names: blank `keys.textContent` in
// `paintHelp`. Every one of these is a string off the keymap.
expect(keyed.every((row) => row.keys.trim() !== "")).toBe(true);
expect(keyed.every((row) => (row.description ?? "").trim() !== "")).toBe(true);
// And the pairing itself, for three rows whose keys are the keymap's:
// `h j k l` and the arrows are four bindings compacted into one row, and
// a page that drew keys of its own would not know to.
const described = (text) => keyed.find((row) => row.description === text)?.keys;
expect(described("Move one cell")).toBe("h j k l ← ↑ → ↓");
expect(described("Next page-axis item")).toBe("]");
expect(described("Drill into selected cell")).toBe(">");
await page.keyboard.press("Escape");
await expect(page.locator("#help")).toBeHidden();
await expect(page.locator("#mode")).toHaveText("NORMAL");
});
// The assertions above are still about a string on a screen. This one asks
// whether the string is TRUE: the key the overlay advertises is read out of
// the DOM and then pressed, so a page drawing a plausible key it does not
// have — the exact failure a hand-spelled table decays into — is caught
// without this spec holding a second copy of the keymap.
test("a key the overlay advertises does what the overlay says it does", async ({
page,
}) => {
await openHelp(page);
await page.keyboard.press("l");
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
const rows = await pairs(page);
const advertised = rows.find((row) => row.description === "Jump to last row")?.keys;
// Not `toBeTruthy`: an empty string is falsy and would read as "the row
// is missing", which is a different fault with a different repair.
expect(advertised, "no row advertises jump-to-last-row").not.toBe(undefined);
expect(advertised.trim(), "the row is drawn, and drawn blank").not.toBe("");
await page.keyboard.press("Escape");
await expect(page.locator("#help")).toBeHidden();
const rows_ = page.locator("#grid tbody tr");
await expect(rows_.first().locator("td.selected")).toHaveCount(1);
await expect(rows_.last().locator("td.selected")).toHaveCount(0);
// One binding per row is the compaction (`h j k l` is four), so press
// the first key the row names rather than the whole string.
await page.keyboard.press(advertised.trim().split(/\s+/)[0]);
await expect(rows_.last().locator("td.selected")).toHaveCount(1);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
// improvise-1c5m. Below the width at which the app bar wraps, `#help` covers
// `#hint`, and Welcome itself says nothing about paging. The contract: the
// mode's own hint is UNCOVERED somewhere while help is open.
test("a narrow window still shows how to page through help", async ({ page }) => {
await page.setViewportSize({ width: 600, height: 800 });
await openHelp(page);
// The string is the page's own `#hint`, i.e. `hint_line(Help)`; this spec
// spells no keys. Asserting it names paging keeps an empty hint out.
const hint = (await page.locator("#hint").textContent()).trim();
expect(hint).toContain("next-page");
// An element showing exactly that string, topmost at its own centre: the
// overlay covering `#hint` is precisely what `elementFromPoint` sees.
const uncovered = await page.evaluate((text) =>
[...document.querySelectorAll("body *")].some((node) => {
if (node.children.length > 0 || node.textContent.trim() !== text) return false;
const r = node.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return top !== null && (top === node || node.contains(top));
}),
hint);
expect(uncovered, "the paging hint is under the help overlay").toBe(true);
});
});