Files
learnharness/tests/render-settings.test.tsx
T
amosblomqvist ad61301324 Appearance wave: searchable reading-font dropdown + emphasis inks default to text color
Two owner rulings (2026-09-26):

READING FONT — the free-text CommitInput is retired; a searchable
dropdown of the user's INSTALLED fonts over model-menu's exact
fuzzy + arrow-walk + Enter machine (FontMenuList in model-menu.tsx,
fed by window.queryLocalFonts in the desktop shell):

- The DEFAULT row (Source Sans 3) is a fuzzy CANDIDATE, not a ghost:
  fontMenuResults (pure, pinned) matches it on empty/source/sans/
  default queries only, so an unfiltered list opens with the default
  first and a searched list drops it honestly — Enter can never pick
  a row that isn't on screen. Picking it writes fontFamily null
  (empty = Source Sans 3 semantics preserved).
- The enumeration is CLICK-bound (queryLocalFonts requires user
  activation — it cannot be a mount effect), deduped to unique
  families, the factory family excluded (the DEFAULT row is it),
  cached for the section's life; a failed enumeration falls to the
  curated list rather than an empty picker.
- Dev-browser fallback is honest, never fabricated: queryLocalFonts
  absent → a small curated list (the app's own knowns + ubiquitous
  macOS families) with the footer 'Common families — your installed
  list needs the desktop app'. The manual escape lives inside the
  menu: a custom-family input + Set commits any family name the
  enumeration didn't surface (the old free-text input's heir).
- composeFontFamily (pure, pinned): null/blank → null, else verbatim.
- The trigger gets a Hint (tooltip wave convention); the row's
  empty-state hint keeps reading 'Empty = Source Sans 3'.

⚠ electron/main.cjs — one line, FLAGGED FOR PLATFORM: 'local-fonts'
joined the allowedPermissions set (the Local Font Access grant the
picker needs in the desktop shell; the loopback-origin discipline is
untouched).

EMPHASIS INKS — the factory #ee928c/#6fa9d6 are gone: both globals.css
theme blocks now default --lh-ink-strong/--lh-ink-italic to
var(--foreground) — unset emphasis renders the regular text color.
Explicit hex overrides ride unchanged (the appearance seam was already
null-honest); the rows' empty-state labels now say 'same as text'.

Tests (+16, 983 total): fontMenuResults default-row candidacy +
filtering, composeFontFamily null/blank/verbatim, the curated roster
(no factory-family duplicate), FontMenuList SSR shape (default row,
families, effective highlight, custom escape, loading state), the
trigger-replaces-input pin, and the globals.css factory pin (no old
hexes in the declarations; var(--foreground) ×2 in both blocks).

Live-verified on :3001 (dev browser — the curated-fallback path):
dropdown opens with the default row + curated list + honest footer;
search filters ('serif' → CMU Serif only); picking Georgia lands
record + --font-prose live; the default row unsets (var cleared);
the custom input commits Baskerville verbatim; Esc closes the menu
WITHOUT closing the dialog. Ink: strong/em computed = rgb(230,230,230)
(the text color) at factory; #ff0000 through the dialog renders red
and clears back cleanly. The desktop queryLocalFonts path cannot run
in this environment — the seam is click-bound + cached with the
failure fallback, and needs one desktop-shell smoke.
2026-09-25 20:02:19 +03:00

249 lines
13 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 type { GraphSettings } from "../src/core/index.ts";
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, fix wave: unpicked reads —, no Default option)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={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("The main model for your sessions") || slot !== "strong", "strong roster");
}
// Fix wave: no Default anywhere — an unpicked slot reads "—"
// (the owner's no-factory-fallback language), and no menu
// carries a Default row (menu-default testid is retired).
assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 0, "no Default trigger");
assert.ok(!html.includes('data-testid="menu-default"'), "no Default row");
assert.equal((html.match(/>—<\/span>/g) ?? []).length, 6, "three slots × two — 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} graph={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} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
assert.ok(html2.includes("Tavily"), "pinned engine visible in the trigger");
});
it("the prober graph-tools row: FIRST in the Tools group, default ON, its own key (wave 7)", () => {
// Default state: graph on, graphTools absent → ON, the describing
// hint rides (the search-row hint discipline).
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const toolsGroup = html.split('data-testid="group-tools"')[1] ?? "";
const rows = [...toolsGroup.matchAll(/data-testid="(graph-tools-row|tool-row-\w+)"/g)].map((m) => m[1]);
assert.equal(rows[0], "graph-tools-row", "graph tools is the FIRST row of the Tools group");
// (the apostrophe in "sessions'" HTML-escapes — match up to it)
assert.ok(html.includes("Lets the tutor consult your knowledge map between sessions"), "the describing hint");
assert.ok((html.split('data-testid="graph-tools-row"')[1] ?? "").includes('aria-checked="true"'), "absent = on");
const rowHtml = html.split('data-testid="graph-tools-row"')[1]?.split('data-testid="tool-row-')[0] ?? "";
assert.ok(!rowHtml.includes("opacity-40"), "lit while the graph is on (the search row may dim — no keys in SSR)");
});
it("graph disabled dims the graph-tools row with the honest hint (the predicate composes)", () => {
const html = renderToStaticMarkup(
<AgentSection agent={undefined} models={undefined} graph={{ enabled: false }} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
const row = html.split('data-testid="graph-tools-row"')[1]?.split('data-testid="tool-row-')[0] ?? "";
assert.ok(row.includes("opacity-40"), "row dims when the graph is off");
assert.ok(row.includes("Graph is disabled"), "the reason hint replaces the description");
assert.ok(row.includes("disabled"), "toggle untouchable while the graph is off");
});
it("an explicit graphTools off shows stored-off (the record never lies)", () => {
const html = renderToStaticMarkup(
<AgentSection agent={{ graphTools: { enabled: false } }} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
const row = html.split('data-testid="graph-tools-row"')[1] ?? "";
assert.ok(row.includes('aria-checked="false"'), "stored-off renders off");
});
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} graph={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} graph={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} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
assert.ok(html.includes("Loading prompt segments"));
});
});
describe("GraphSection — the collapsed master switch (wave 7)", () => {
// Wave 7 (owner ruling 2026-09-23): the section is the master switch
// ONLY — the embedding/clustering/labeling rungs and the BYOK model
// picker are gone (clustering is the free birth-session partition,
// labels ARE session names). One row, one toggle.
it("renders the single master-switch row", () => {
const html = renderToStaticMarkup(<GraphSection graph={undefined} onSectionChange={() => {}} />);
assert.ok(html.includes('data-testid="graph-row-enabled"'));
assert.ok(!html.includes("graph-row-embedding"), "the dead rungs render nothing");
assert.ok(!html.includes("Embedding model"), "the picker is gone");
assert.ok(!html.includes("graph-row-clustering"));
assert.ok(!html.includes("graph-row-labeling"));
assert.ok(tagOf(html, "toggle-graph-enabled").includes('aria-checked="true"'), "absent record = factory ON");
});
it("a stored off record shows the switch off", () => {
const html = renderToStaticMarkup(<GraphSection graph={{ enabled: false }} onSectionChange={() => {}} />);
assert.ok(tagOf(html, "toggle-graph-enabled").includes('aria-checked="false"'));
});
});
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("Coming soon"));
// 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"`));
// The reading font is the searchable dropdown now (font-menu
// wave): its trigger shows the factory family; the emphasis
// inks' empty state is "same as text" (the old hexes died with
// the factory token change).
assert.ok(html.includes('data-testid="font-trigger"'));
assert.equal((html.match(/Empty = same as text/g) ?? []).length, 2);
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("Fixed — part of this screen"));
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"`));
});
});