Files
learnharness/tests/embedding-settings.test.tsx
T
amosblomqvist fb66606555 Wave 6 chunk B: the embeddings surfaces — picker, re-embed gate, gray-outs, cost pill
- Settings ▸ Graph grows the embedding-model picker (graph-row-embedding-model):
  GET /api/models/embeddings' live-federated BYOK catalog (re-fetched on mount
  and every settings save), rows priced from EMBEDDING_PRICES ("$x.xx/1M in",
  honestly "—" when unlisted), ids shown AND stored as full colon-safe slugs
  (openrouter:liquid/…:free is never split), plus an Unset row writing null.
  Empty catalog = nothing keyed: the trigger BECOMES the deep link to Keys
  ("Add a provider key first…") — and deep-links fired while the dialog is
  open now actually switch sections (initialSection was read once at mount;
  the body follows it now — pre-existing gap, first exercised by this link).
- Re-embed gate (owner design): picking a model that differs from the stored
  one holds the write behind a confirm strip carrying the honest node count
  from GET /api/graph; zero propositions skip the strip (nothing to re-embed)
  and land directly. Unset never confirms — the port just stops resolving.
  The copy says the re-embed runs with the next background pass (the staleness
  machinery — a settings PUT alone never triggers it; no server endpoints
  were added).
- Gray-out, the searchStatus precedent: with no model configured, the ladder
  rungs Embedding/Clustering/Labeling dim + disable with a "Needs an
  embedding model" hint (master switch stays lit), and the stats-bar
  GraphContextControl swaps its slider for a dimmed deep-link button to the
  Graph section. Loading never grays; every settings save re-reads (signal).
- Cost chrome: the pipeline popover grows the Embeddings stage row
  (costs.stages.embedding, wave 6's folded stage — "— · 0 in / 0 out" until
  runs exist, same as its siblings).
- Master's ride-along: the core-barrel ChatDeleteMode comment now says two
  type-only things (it always exported both).

Gates: tsc clean, 912/912 (8 new: price lines, model-dependency predicate,
unset/set SSR dimming; 2 ladder fixtures updated to the new no-model
semantics), lint 0, build ok. Live-verified on :3001: unset deep-link →
graph section, menu pricing + colon-safety (37 options), confirm strip with
"20 propositions", Cancel keeps the record, Unset writes immediately,
in-dialog empty-catalog deep-link lands Keys, Embeddings row in the popover.
2026-09-23 12:50:59 +03:00

91 lines
3.8 KiB
TypeScript

import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
embeddingPriceLabel,
GraphSection,
needsEmbeddingModel,
} from "../components/settings/appearance-section.tsx";
import { EMBEDDING_PRICES, type GraphSettings } from "../src/core/index.ts";
/**
* Wave 6 chunk B — the embeddings settings seams: the price line for
* catalog rows (EMBEDDING_PRICES lookup, honestly "—" when unlisted),
* the model-dependency predicate that drives the ladder gray-out, and
* GraphSection's SSR shape with and without a configured model.
*
* The fetches (catalog, node count) live in effects — dead under
* renderToStaticMarkup, so the render pins the STORED-state markup only;
* the interactive round-trips are live-verified on the dev server.
*/
describe("embeddingPriceLabel", () => {
it("prices a listed model: USD per 1M input tokens", () => {
// Straight from the hand-maintained table (openai small = $0.02).
assert.equal(embeddingPriceLabel("openai:text-embedding-3-small"), "$0.02/1M in");
});
it("prices every entry in EMBEDDING_PRICES without zero or 'NaN' leaking", () => {
for (const id of Object.keys(EMBEDDING_PRICES)) {
const label = embeddingPriceLabel(id);
assert.match(label, /^\$\d+(\.\d+)?\/1M in$/);
assert.notEqual(label, "$0/1M in");
}
});
it("trims a trailing zero the model-menu way ($0.2, not $0.20)", () => {
assert.equal(embeddingPriceLabel("google:gemini-embedding-2"), "$0.2/1M in");
});
it("stays honestly '—' for unlisted models (never zero)", () => {
assert.equal(embeddingPriceLabel("openrouter:some/model-not-in-table"), "—");
});
});
describe("needsEmbeddingModel", () => {
it("spares only the master switch", () => {
assert.equal(needsEmbeddingModel("enabled"), false);
assert.equal(needsEmbeddingModel("embedding"), true);
assert.equal(needsEmbeddingModel("clustering"), true);
assert.equal(needsEmbeddingModel("labeling"), true);
});
});
describe("GraphSection (SSR)", () => {
const render = (graph: GraphSettings | undefined) =>
renderToStaticMarkup(<GraphSection graph={graph} onSectionChange={() => {}} />);
it("grays out + hints the model-dependent rungs when no model is set", () => {
const html = render({ enabled: true } as GraphSettings);
// Master switch: never model-dimmed.
const enabledRow = html.split('data-testid="graph-row-enabled"')[1]?.split("</div>")[0] ?? "";
assert.ok(!enabledRow.includes("opacity-40"), "master switch must not dim on unset model");
// The three dependent rungs dim and each carries the hint.
for (const key of ["embedding", "clustering", "labeling"] as const) {
const row = html.split(`data-testid="graph-row-${key}"`)[1]?.split('data-testid="graph-row-')[0] ?? "";
assert.ok(row.includes("opacity-40"), `${key} row dims without a model`);
assert.ok(row.includes("Needs an embedding model"), `${key} row carries the hint`);
}
assert.ok(html.includes('data-testid="graph-row-embedding-model"'));
});
it("keeps the ladder touchable once a model is set", () => {
const html = render({
enabled: true,
embeddingModel: "openai:text-embedding-3-small",
} as GraphSettings);
for (const key of ["embedding", "clustering", "labeling"] as const) {
const row = html.split(`data-testid="graph-row-${key}"`)[1]?.split('data-testid="graph-row-')[0] ?? "";
assert.ok(!row.includes("opacity-40"), `${key} row stays lit with a model`);
assert.ok(!row.includes("Needs an embedding model"), "no hint with a model");
}
});
it("renders the picker row (catalog fetch is an effect — inert in SSR)", () => {
const html = render(undefined);
assert.ok(html.includes("Loading…"), "pre-fetch trigger state renders");
});
});