ui: unify practice composer actions and node picker copy

This commit is contained in:
amosblomqvist
2026-10-17 01:06:28 +03:00
parent 487a63eaa5
commit 3d1ad38e46
6 changed files with 184 additions and 97 deletions
+86
View File
@@ -0,0 +1,86 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ComposerActions, NodePickerControl } from "../components/composer-actions.tsx";
import { ZeroKeysPill } from "../components/setup-cta.tsx";
import { DraftShell } from "../components/new-session.tsx";
const modelSetup = <button data-testid="setup-cta">Set up your models</button>;
const keySetup = <ZeroKeysPill inline onOpen={() => {}} />;
function documentFor(node: React.ReactNode) {
return parseHTML(`<html><body>${renderToStaticMarkup(node)}</body></html>`).document;
}
describe("composer action row", () => {
it("renders nothing when neither action exists", () => {
assert.equal(renderToStaticMarkup(<ComposerActions picker={false} setup={false} />), "");
});
for (const [label, setup] of [["provider key", keySetup], ["models", modelSetup]] as const) {
it(`${label}: setup-only stays centered`, () => {
const document = documentFor(<ComposerActions setup={setup} />);
const row = document.querySelector('[data-testid="composer-actions"]')!;
assert.ok(row.classList.contains("justify-center"));
assert.equal(row.children.length, 1);
assert.equal(row.firstElementChild!.tagName, "BUTTON", "setup must not reserve a nested full-width row");
});
it(`${label}: picker leads in one left-aligned wrapping row`, () => {
const document = documentFor(<ComposerActions picker={<NodePickerControl count={0} />} setup={setup} />);
const row = document.querySelector('[data-testid="composer-actions"]')!;
const chip = document.querySelector('[data-testid="practice-props-chip"]')!;
assert.ok(row.classList.contains("justify-start"));
assert.ok(row.classList.contains("flex-wrap"));
assert.ok(row.classList.contains("pointer-events-auto"));
assert.equal(row.children.length, 2);
assert.equal(chip.parentElement, row.firstElementChild);
assert.equal(row.lastElementChild!.tagName, "BUTTON");
assert.equal(chip.textContent, "Pick nodes");
assert.equal(document.querySelector('[data-testid="practice-props-clear"]'), null);
});
it(`${label}: the practice draft uses that same row and order`, () => {
const document = documentFor(<DraftShell busy={false} text="" onChange={() => {}} onSend={() => {}} mode="practice" setupCta={setup} />);
const rows = document.querySelectorAll('[data-testid="composer-actions"]');
assert.equal(rows.length, 1);
assert.ok(rows[0]!.classList.contains("justify-start"));
assert.ok(rows[0]!.firstElementChild!.querySelector('[data-testid="practice-props-chip"]'));
assert.equal(rows[0]!.lastElementChild!.tagName, "BUTTON");
});
}
it("picker-only remains left-aligned", () => {
const document = documentFor(<ComposerActions picker={<NodePickerControl count={0} />} />);
assert.ok(document.querySelector('[data-testid="composer-actions"]')!.classList.contains("justify-start"));
});
it("singular and plural node counts keep Clear in the same wrapping group", () => {
for (const count of [1, 2]) {
const document = documentFor(<ComposerActions picker={<NodePickerControl count={count} />} setup={modelSetup} />);
const chip = document.querySelector('[data-testid="practice-props-chip"]')!;
const clear = document.querySelector('[data-testid="practice-props-clear"]')!;
assert.equal(chip.textContent, `${count} ${count === 1 ? "node" : "nodes"} selected`);
assert.equal(chip.parentElement, clear.parentElement);
assert.equal(chip.parentElement!.parentElement!.getAttribute("data-testid"), "composer-actions");
assert.ok(!chip.parentElement!.classList.contains("flex-wrap"), "count and Clear never wrap apart");
}
});
it("existing chat callers retain the standalone provider-key pill wrapper", () => {
const document = documentFor(<ZeroKeysPill onOpen={() => {}} />);
const wrapper = document.body.firstElementChild!;
assert.equal(wrapper.tagName, "DIV");
assert.ok(wrapper.classList.contains("justify-center"));
assert.ok(wrapper.classList.contains("mb-2"));
});
it("the practice shell preserves a selected node in teaching mode", () => {
const document = documentFor(<DraftShell busy={false} text="" onChange={() => {}} onSend={() => {}} mode="teach" picks={[{id:"n_1",name:"Parity",statement:"Odd + odd = even."}]} />);
assert.equal(document.querySelector('[data-testid="practice-props-chip"]')!.textContent, "1 node selected");
assert.ok(document.querySelector('[data-testid="practice-props-clear"]'));
});
});
+4 -4
View File
@@ -122,7 +122,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => {
/>,
);
assert.match(html, /New practice session/);
assert.match(html, /Describe what to practice/);
assert.ok(html.includes("Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem."));
assert.match(html, /What do you want to practice\?/);
});
@@ -136,7 +136,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => {
mode="practice"
/>,
);
assert.match(html, /Pick propositions from the map/);
assert.match(html, />Pick nodes<\/button>/);
// picked: count + Clear, the home register verbatim
const picked = renderToStaticMarkup(
<DraftShell
@@ -148,13 +148,13 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => {
picks={[{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }]}
/>,
);
assert.match(picked, /1 proposition selected/);
assert.match(picked, /1 node selected/);
assert.match(picked, /Clear/);
// teach mode with nothing picked renders nothing (home's law)
const teach = renderToStaticMarkup(
<DraftShell busy={false} text="" onChange={() => {}} onSend={() => {}} mode="teach" />,
);
assert.ok(!teach.includes("Pick propositions from the map"));
assert.ok(!teach.includes('data-testid="practice-props-chip"'));
});
it("startDraftSession carries the picks as graphContext (the home practice contract)", async () => {