From 93ae435287810369d8d4ea763e618bcbcca60256 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 14 Oct 2026 14:51:45 +0200 Subject: [PATCH] swarm-ui: drop an unused export and test the classifier settings logic The runtime list was exported but only its type was used, so the type is now written out. The form's pure logic (draft, checks, request body, change detection) gets tests under node's built-in runner, run by the existing swarm-ui check. --- frontend/packages/swarm-ui/package.json | 3 +- .../pages/review-settings/SettingsTypes.ts | 3 +- .../swarm-ui/test/settings-types.test.mjs | 119 ++++++++++++++++++ nix/checks.nix | 4 + 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 frontend/packages/swarm-ui/test/settings-types.test.mjs diff --git a/frontend/packages/swarm-ui/package.json b/frontend/packages/swarm-ui/package.json index dae581ff..d83a859b 100644 --- a/frontend/packages/swarm-ui/package.json +++ b/frontend/packages/swarm-ui/package.json @@ -6,7 +6,8 @@ "type": "module", "scripts": { "build": "node ./build.mjs", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "node --test test/*.test.mjs" }, "dependencies": { "@hive/shared": "*", diff --git a/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts b/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts index 0453e3ac..0e30e61f 100644 --- a/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts @@ -8,8 +8,7 @@ export type Effort = (typeof EFFORTS)[number]; /** Which runtime a classification runs on: the claude CLI on the Anthropic * API key, or opencode on an ACP provider. */ -export const RUNTIMES = ["api-key", "acp"] as const; -export type Runtime = (typeof RUNTIMES)[number]; +export type Runtime = "api-key" | "acp"; /** What the provider charges, in USD per million tokens. A cache price left * out counts as `input`. */ diff --git a/frontend/packages/swarm-ui/test/settings-types.test.mjs b/frontend/packages/swarm-ui/test/settings-types.test.mjs new file mode 100644 index 00000000..e76cddfa --- /dev/null +++ b/frontend/packages/swarm-ui/test/settings-types.test.mjs @@ -0,0 +1,119 @@ +// The classifier settings form's pure logic: the draft a stored document +// becomes, the checks before a save, the request body, and what counts as a +// change. Run by `npm test`, which Node runs with its built-in test runner and +// type stripping, so the `.ts` module is imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + changed, + draftOf, + maxValue, + priceValue, + settingsProblem, + toSettings, +} from "../src/pages/review-settings/SettingsTypes.ts"; + +const apiKey = { + model: "haiku", + effort: null, + max_concurrent_reviews: 2, + runtime: "api-key", + acp: null, +}; + +const acpStored = { + ...apiKey, + runtime: "acp", + acp: { + base_url: "https://llm.example/v1", + model: "Qwen/Qwen3-35B", + prices: { input: 0.5, output: 2, cache_read: 0.1 }, + }, +}; + +// An ACP draft over the API key settings, with every provider field filled. +function acpDraft(over = {}) { + return { + ...draftOf(apiKey), + runtime: "acp", + acpUrl: "https://llm.example/v1", + acpModel: "Qwen/Qwen3-35B", + priceInput: "0.5", + priceOutput: "2", + ...over, + }; +} + +test("an unedited draft is valid and changes nothing", () => { + const d = draftOf(apiKey); + assert.equal(settingsProblem(d), null); + assert.equal(changed(d, apiKey), false); +}); + +test("the API key request leaves the provider out, so the stored one stays", () => { + const body = toSettings({ ...draftOf(acpStored), runtime: "api-key" }); + assert.deepEqual(body, { + model: "haiku", + effort: null, + max_concurrent_reviews: 2, + runtime: "api-key", + }); + assert.equal("acp" in body, false); +}); + +test("the ACP request carries the provider and its prices", () => { + const body = toSettings(acpDraft({ priceCacheRead: "0.1" })); + assert.equal(body.runtime, "acp"); + assert.deepEqual(body.acp, { + base_url: "https://llm.example/v1", + model: "Qwen/Qwen3-35B", + prices: { input: 0.5, output: 2, cache_read: 0.1 }, + }); +}); + +test("a cache price left empty is left out of the request", () => { + const { prices } = toSettings(acpDraft()).acp; + assert.equal("cache_read" in prices, false); + assert.equal("cache_write" in prices, false); +}); + +test("the draft of a stored provider round-trips to the same request", () => { + const body = toSettings(draftOf(acpStored)); + assert.deepEqual(body.acp, acpStored.acp); + assert.equal(changed(draftOf(acpStored), acpStored), false); +}); + +test("the provider fields are checked only on the ACP runtime", () => { + const empty = { ...draftOf(apiKey), acpUrl: "http://nope" }; + assert.equal(settingsProblem(empty), null); + assert.match(settingsProblem({ ...empty, runtime: "acp" }), /https:\/\//); +}); + +test("the ACP checks name the field at fault", () => { + assert.match(settingsProblem(acpDraft({ acpUrl: "http://x" })), /https:\/\//); + assert.match(settingsProblem(acpDraft({ acpModel: "a b" })), /model/); + assert.match(settingsProblem(acpDraft({ priceOutput: "-1" })), /price/); + assert.match(settingsProblem(acpDraft({ priceInput: "" })), /price/); + assert.match(settingsProblem(acpDraft({ priceCacheWrite: "x" })), /cache/); + assert.equal(settingsProblem(acpDraft()), null); +}); + +test("switching the runtime is a change; edits to the hidden provider are not", () => { + const saved = apiKey; + assert.equal(changed({ ...draftOf(saved), runtime: "acp" }, saved), true); + assert.equal( + changed({ ...draftOf(saved), acpUrl: "https://x" }, saved), + false, + ); + assert.equal(changed(acpDraft({ priceOutput: "3" }), acpStored), true); +}); + +test("the reviews limit and a price parse as the form needs", () => { + assert.equal(maxValue("8"), 8); + assert.equal(maxValue("9"), null); + assert.equal(maxValue("1.5"), null); + assert.equal(priceValue("0"), 0); + assert.equal(priceValue("0.25"), 0.25); + assert.equal(priceValue("-1"), null); + assert.equal(priceValue("1e3"), null); +}); diff --git a/nix/checks.nix b/nix/checks.nix index 6314207e..1bcf7fc3 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -369,6 +369,10 @@ in buildPhase = '' runHook preBuild npm run typecheck --workspace=packages/swarm-ui + # The package's own tests (`test/`, Node's built-in runner) ride this + # derivation: it already has the workspace installed, and a second + # `npm ci` for the same tree would only repeat it. + npm run test --workspace=packages/swarm-ui runHook postBuild '';