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 '';