Files
learnharness/tests/render-settings.test.tsx
T
amosblomqvist 48fbd988f1 Wave-5 chunk B: three model slots, search-engine row, first-run setup flow
Chunk B (owner-designed, model-slots wave) on the merged engine surface
(main 1d66a17):

AGENT SECTION — SLOTS ONLY (owner ruling)
- The six per-role model rows are GONE; three slot rows instead:
  Strong (Prober + Teacher), Cheap (five workhorse roles), Vision
  (visual maker + PDF reader), roster + resolved-default in every hint.
- Slot dropdowns share the app's menu language over the FEDERATED
  catalog (/api/models = keyed providers only, fresh per call — saving
  a key in Keys immediately widens the picker); the vision slot
  filters to image-capable models (Model.input); the menu's highlight
  now matches provider+id (ids can collide across providers).
- Slots write the new models section (PUT /api/settings/models) through
  the dialog's write seam; per-role overrides stay schema-only.
- ModelMenuList rows read 'provider · id' — federation makes the
  provider bookkeeping-relevant. Usage stamps render provider-qualified
  (prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats
  bar's turn lines.

SEARCH ENGINE (search wave: no default, owner)
- A row in Tools: Tavily/Brave via the shared-menu look, Automatic =
  the pin removed (auto-activation). The live reason line comes from
  /api/search; no engine key at all grays the whole row + disables the
  trigger (a keyless choice would only block auto-activation).

FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception)
- Chats cannot start until all three slots are picked — no defaults
  for 33 providers (slotsConfigured: all three slot models required).
- The graph view's new-session dock (home) shows a 'Set up your models'
  CTA while unconfigured; submitting a topic opens the SetupDialog WITH
  THE DRAFT PRESERVED; Save stores the models section and the queued
  message sends. Same interception on /chat/new sends.
- STAGED BY DEPENDENCY (owner: keys first, models after): no keyed
  provider → stage 1 deep-links the settings dialog on Keys (open now
  takes a section); a saved key reopens at stage 2 on the next submit.
- Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered,
  all three required). Closable without configuring — browsing stays
  possible; the gate is the send, not the dialog. Layered Esc: an open
  slot menu eats Esc before the dialog.
- Two closure bugs caught live: onDone→start/send captured the stale
  pre-save 'configured' (the flow now forces the replayed send); menu-Esc
  used to close the whole dialog.

Settings-dialog provider: open(section?) lands on a section (stage 1
deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR
replacing role rows, slotsConfigured/setupStage first-run gates,
provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
2026-09-22 13:54:29 +03:00

215 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Settings-surface render tests (thin settings wave): SSR of the section
* components pins the THIN markup — toggle rows, the graph ladder's
* graying, the mode control's honest disabled state, the role rows and
* tool rows. Fetches don't run under renderToStaticMarkup (effects never
* fire), so the SSR state IS the loading state — asserted as such.
* Same tsx classic-JSX shim as render-sidebar.test.tsx.
*/
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 { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section.tsx";
import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx";
import { KeysSection } from "../components/settings/keys-section.tsx";
import { KeyboardSection } from "../components/settings/keyboard-section.tsx";
import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
/** The full opening tag of the element carrying a testid — attribute
* order in React output is not contractual, so assertions run on the
* whole tag instead of ordered regex chains. */
function tagOf(html: string, testid: string): string {
const i = html.indexOf(`data-testid="${testid}"`);
assert.ok(i >= 0, `missing ${testid}`);
return html.slice(html.lastIndexOf("<", i), html.indexOf(">", i) + 1);
}
describe("SettingsDialog (the designed surface, SSR)", () => {
it("provider CLOSED: children only — no dialog, no portal content", () => {
const html = renderToStaticMarkup(
<SettingsDialogProvider>
<p>the app</p>
</SettingsDialogProvider>,
);
assert.ok(html.includes("the app"));
assert.ok(!html.includes("settings-backdrop"));
assert.ok(!html.includes("settings-dialog"));
});
it("the dialog frame: role=dialog + the ✕ + the six-section nav, General active", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
assert.ok(html.includes('role="dialog"'));
assert.ok(html.includes('aria-modal="true"'));
assert.ok(html.includes('aria-label="Close settings"'));
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) {
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
}
// General is the landing section; the others are not current.
assert.ok(tagOf(html, "settings-nav-general").includes('aria-current="true"'));
assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"'));
// Fetches don't run under SSR — the loading state IS the SSR state.
assert.ok(html.includes("Loading settings…"));
// Modal-only doctrine: no link back to /settings anywhere in it.
assert.ok(!html.includes('href="/settings"'));
});
});
describe("AgentSection (thin SSR)", () => {
it("renders the three model SLOT rows (slots-only, owner ruling 2026-09-20), Default-triggered dropdowns", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
for (const slot of ["strong", "cheap", "vision"]) {
assert.ok(html.includes(`data-testid="slot-row-${slot}"`), slot);
// The shared model-picker dropdown as the opened menu (owner,
// 2026-09-20), the thin trigger as the collapsed look.
assert.ok(html.includes(`data-testid="slot-model-${slot}"`), `${slot} model trigger`);
assert.ok(html.includes(`data-testid="slot-thinking-${slot}"`), `${slot} thinking trigger`);
// the roster hint rides every row
assert.ok(html.includes("Prober + Teacher") || slot !== "strong", "strong roster");
}
assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 6, "three slots × two Default triggers");
// the per-role rows are GONE (slots-only)
for (const role of ["researcher", "demoBuilder", "visualMaker"]) {
assert.ok(!html.includes(`data-testid="role-row-${role}"`), `${role} retired`);
}
assert.ok(html.includes('data-testid="group-models"'), "Models sub-group");
assert.ok(html.includes('data-testid="group-tools"'), "Tools sub-group");
assert.ok(html.includes('data-testid="group-prompts"'), "System prompts sub-group");
});
it("the search-engine row renders with its live reason (search wave 2026-09-20)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
assert.ok(html.includes('data-testid="search-engine-row"'), "the row");
assert.ok(html.includes('data-testid="search-engine-trigger"'), "the trigger");
// SSR: /api/search never fired → no reason line yet, trigger reads Automatic
assert.ok(html.includes("Automatic"), "unset engine reads Automatic");
// the explicit preference shows the chosen engine in the trigger
const html2 = renderToStaticMarkup(
<AgentSection agent={{ search: { engine: "tavily" } }} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
assert.ok(html2.includes("Tavily"), "pinned engine visible in the trigger");
});
it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
assert.deepEqual([...TOGGLE_TOOLS], ["research", "demo", "visual", "pdf"]);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
for (const id of TOGGLE_TOOLS) assert.ok(html.includes(`data-testid="tool-row-${id}"`), id);
});
it("tools default ON (absent key = enabled)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
for (const id of TOGGLE_TOOLS) {
assert.ok(tagOf(html, `toggle-tool-${id}`).includes('aria-checked="true"'), `${id} defaults on`);
}
});
it("an overridden SLOT shows its model in the trigger, tools carry the delta", () => {
const html = renderToStaticMarkup(
<AgentSection
agent={{ tools: { demo: false } }}
models={{ strong: { model: { provider: "zai", id: "glm-4.7-flash" } } }}
onSectionChange={() => {}}
onModelsChange={() => {}}
/>,
);
// The trigger shows the stored id even with no catalog loaded (a
// trigger reading "Default" over a live override would lie).
assert.ok(html.includes("Glm 4.7 Flash"), "the override id is visible (prettyModel form)");
assert.ok(tagOf(html, "toggle-tool-demo").includes('aria-checked="false"'), "demo off");
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
});
it("prompts show the loading state under SSR (effects never fire)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
assert.ok(html.includes("Loading prompt segments"));
});
});
describe("GraphSection — the dependency ladder", () => {
it("defaults: all four rungs on, none disabled", () => {
const html = renderToStaticMarkup(<GraphSection graph={undefined} onSectionChange={() => {}} />);
for (const key of ["enabled", "embedding", "clustering", "labeling"]) {
assert.ok(html.includes(`data-testid="graph-row-${key}"`), key);
assert.ok(!tagOf(html, `toggle-graph-${key}`).includes("disabled"), `${key} touchable`);
}
});
it("master off grays EVERY dependent (stored values persist, resolve inert)", () => {
const html = renderToStaticMarkup(
<GraphSection graph={{ enabled: false, embedding: true, clustering: true, labeling: true }} onSectionChange={() => {}} />,
);
for (const key of ["embedding", "clustering", "labeling"]) {
assert.ok(tagOf(html, `toggle-graph-${key}`).includes("disabled"), `${key} disabled`);
}
assert.ok(!tagOf(html, "toggle-graph-enabled").includes("disabled"), "master itself stays touchable");
});
it("a mid-ladder break grays only what's below it", () => {
const html = renderToStaticMarkup(
<GraphSection graph={{ enabled: true, embedding: true, clustering: false }} onSectionChange={() => {}} />,
);
assert.ok(!tagOf(html, "toggle-graph-embedding").includes("disabled"), "embedding touchable");
assert.ok(!tagOf(html, "toggle-graph-clustering").includes("disabled"), "clustering touchable");
assert.ok(tagOf(html, "toggle-graph-labeling").includes("disabled"), "labeling grayed");
});
});
describe("KeysSection (thin SSR)", () => {
it("SSR = the loading state (effects never fire)", () => {
const html = renderToStaticMarkup(<KeysSection />);
assert.ok(html.includes("Loading keys…"));
});
});
describe("AppearanceSection (thin SSR)", () => {
it("mode is rendered DISABLED — dark-only this wave, no lying control", () => {
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled");
assert.ok(html.includes("Light ships with the design wave"));
// The whole row dims, graph-view style (the control never adds its own).
assert.ok(tagOf(html, "row-mode").includes("opacity-40"), "mode row dims whole");
});
it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => {
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
assert.ok(html.includes(`placeholder="#141414"`));
assert.ok(html.includes(`placeholder="Source Sans 3"`));
assert.ok(html.includes(`placeholder="#c96b4a"`));
assert.ok(html.includes("16px"));
});
it("a set background shows in the input, not the placeholder", () => {
const html = renderToStaticMarkup(
<AppearanceSection appearance={{ background: "#1a1a2e" }} onSectionChange={() => {}} />,
);
assert.ok(html.includes(`value="#1a1a2e"`));
});
});
describe("KeyboardSection (thin SSR)", () => {
it("renders every registry action in its scope group", () => {
const html = renderToStaticMarkup(<KeyboardSection />);
for (const def of KEYBOARD_ACTIONS) {
assert.ok(html.includes(`data-testid="kbd-row-${def.id}"`), def.id);
}
assert.ok(html.includes("kbd-scope-global"));
assert.ok(html.includes("kbd-scope-chat"));
});
it("open-settings is in the registry with the settings chord", () => {
const def = KEYBOARD_ACTIONS.find((a) => a.id === "open-settings");
assert.ok(def, "the action exists");
assert.equal(def?.scope, "global");
assert.equal(def?.binding, "mod+,");
});
it("displayOnly actions list but never offer Rebind", () => {
const html = renderToStaticMarkup(<KeyboardSection />);
assert.ok(html.includes("Owned by a surface — not rebindable"));
assert.ok(!html.includes(`data-testid="kbd-rebind-question-next-row"`), "surface keys stay put");
assert.ok(!html.includes(`data-testid="kbd-rebind-model-menu-pick"`));
});
});