summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/ui/ConcurrencyView.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 13:18:25 +0900
committerAdam Malczewski <[email protected]>2026-06-28 14:41:47 +0900
commit0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch)
treeb198da2719987ffd58e9c2633fc36684cf2ad316 /src/features/concurrency/ui/ConcurrencyView.test.ts
parenta59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff)
downloaddispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.tar.gz
dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.zip
feat(concurrency): configurable cooldown + adaptive-headroom auto-reduce banner
Consumes the backend's concurrency-fixes (commit 2d27666) — additive to [email protected], NO version bump. ConcurrencyStatusEntry gains cooldownMs / autoReduced / autoReducedFrom? / notice?; new ConcurrencyCooldownResponse / SetConcurrencyCooldownRequest + GET/PUT /concurrency/cooldown/:providerId. FE: - Pure core (logic/view-model.ts): DEFAULT_COOLDOWN_MS; parseCooldownInput (non-negative int — 0 is valid, unlike the limit); normalizeCooldown; cooldownLabel ("350ms"/"1.2s"/"0ms (off)"); viewConcurrencyStatus extended (cooldown + autoReduce fields; auto-reduce → warning badge, not busy); viewAutoReduce/autoReduceNotices (banner view — prefers backend notice, synthesizes a fallback); summarizeStatus "N auto-reduced" fragment; normalizeConcurrencyStatus coerces new fields (immutable readonly build; autoReducedFrom/notice only when autoReduced===true); normalizeConcurrencyCooldown. - Types (logic/types.ts): re-export the 2 new contract types + ConcurrencyCooldownResult + Get/SaveConcurrencyCooldown ports. - UI: ConcurrencyCooldownRow.svelte (inline-edit cooldown + Save → PUT, seeded via the ChatLimitField pattern); AutoReduceBanner.svelte (dismissible banner — backend notice + "Was N, now M." + "Restore to N"); ConcurrencyView renders the cooldown per status card + the banner section. The banner persists while autoReduced===true, is dismissible, and clears automatically once a poll shows autoReduced===false after a restore PUT. - Store (store.svelte.ts): getConcurrencyCooldown + setConcurrencyCooldown (surface 400/404/503 as ok:false; normalize at the seam) + interface decls. - App.svelte: saveConcurrencyCooldown adapter → ConcurrencyView. - Tests: +47 (view-model cooldown/auto-reduce/normalizers; component banner render, cooldown PUT, restore clears banner, dismiss; store cooldown GET/PUT). Re-synced the file: dep (bun install) + re-mirrored .dispatch/transport-contract. reference.md. typecheck 0/0, 1048 tests green (run twice), biome clean, build OK. Worktree env: an untracked dispatch-backend → backend symlink was created in the worktree parent so the canonical file:../dispatch-backend/... paths resolve (NOT committed — per the §2d/§2j worktree convention).
Diffstat (limited to 'src/features/concurrency/ui/ConcurrencyView.test.ts')
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts339
1 files changed, 279 insertions, 60 deletions
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
index 3dc8e78..854f13b 100644
--- a/src/features/concurrency/ui/ConcurrencyView.test.ts
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type {
+ ConcurrencyCooldownResult,
ConcurrencyDeleteResult,
ConcurrencyLimitResult,
ConcurrencyLimitsResult,
@@ -13,35 +14,80 @@ import ConcurrencyView from "./ConcurrencyView.svelte";
// Available models → provider ids are "umans", "anthropic", "openai-compat".
const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const;
-// Fakes for the four injected ports. Each resolves immediately so the mount
-// effect's initial load settles in a microtask (assertions await via findBy*).
+// A status entry factory (defaults to a healthy limited provider). The new
+// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present.
+function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry {
+ return {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ ...over,
+ };
+}
+// Fakes for the injected ports. Each resolves immediately so the mount effect's
+// initial load settles in a microtask (assertions await via findBy*). The status
+// list is mutable so a test can flip `autoReduced` between polls to simulate a
+// restore clearing the banner.
function makeFakes(opts?: {
limits?: readonly { providerId: string; limit: number }[];
- status?: readonly ConcurrencyStatusEntry[];
+ status?: ConcurrencyStatusEntry[];
+ /**
+ * When set, `saveLimit` rejects with this error (returns `ok: false`) — used
+ * to test the auto-reduce banner's inline restore-error feedback.
+ */
+ saveLimitError?: string;
+ /**
+ * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a
+ * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced`
+ * on the next status poll). Receives the providerId + limit + the fakes bag so
+ * it can mutate the status list. (A plain method reassignment would NOT reach
+ * the already-rendered component — the prop captured the original closure.)
+ */
+ onSaveLimit?: (
+ providerId: string,
+ limit: number,
+ self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void },
+ ) => void;
}) {
let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }];
- const status = opts?.status ?? [
- { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
- ];
+ let status = opts?.status ?? [statusEntry()];
+ const onSaveLimit = opts?.onSaveLimit;
+ const saveLimitError = opts?.saveLimitError;
- const calls = {
+ const calls: MakeFakesCalls = {
loadLimits: 0,
loadStatus: 0,
saves: [] as { providerId: string; limit: number }[],
deletes: [] as string[],
+ cooldownSaves: [] as { providerId: string; cooldownMs: number }[],
};
+ function setStatus(next: ConcurrencyStatusEntry[]): void {
+ status = next;
+ }
+
return {
calls,
+ // Allow a test to mutate the status list between polls (e.g. clear
+ // autoReduced after a restore to simulate the next poll).
+ setStatus,
loadLimits: async (): Promise<ConcurrencyLimitsResult> => {
calls.loadLimits++;
return { ok: true, limits };
},
saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
calls.saves.push({ providerId, limit });
+ if (saveLimitError !== undefined) {
+ return { ok: false, error: saveLimitError };
+ }
// Reflect the new limit into the list the next load returns.
limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }];
+ if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus });
return { ok: true, providerId, limit };
},
deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => {
@@ -53,21 +99,41 @@ function makeFakes(opts?: {
calls.loadStatus++;
return { ok: true, providers: status };
},
+ saveCooldown: async (
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> => {
+ calls.cooldownSaves.push({ providerId, cooldownMs });
+ // Reflect the new cooldown into the status list the next load returns.
+ status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s));
+ return { ok: true, providerId, cooldownMs };
+ },
+ };
+}
+
+type MakeFakesCalls = {
+ loadLimits: number;
+ loadStatus: number;
+ saves: { providerId: string; limit: number }[];
+ deletes: string[];
+ cooldownSaves: { providerId: string; cooldownMs: number }[];
+};
+
+function props(fakes: ReturnType<typeof makeFakes>) {
+ return {
+ models: MODELS as unknown as readonly string[],
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ saveCooldown: fakes.saveCooldown,
};
}
describe("ConcurrencyView", () => {
it("loads + renders the configured limits and live status on mount", async () => {
const fakes = makeFakes();
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
// The provider dropdown is populated from the available models' providers.
const providerSelect = await screen.findByLabelText("Provider");
@@ -81,21 +147,21 @@ describe("ConcurrencyView", () => {
expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
});
+ it("renders the per-provider cooldown label from the live status", async () => {
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The status card shows the cooldown alongside in-flight/queue.
+ expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument();
+ });
+
it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => {
// The 2s status poll + post-mutation reloads are SILENT: they never toggle a
// visible loading state, so the Refresh buttons are plain-text (no spinner)
// and the list area never reflows mid-refresh. Regression guard for the
// flicker fix (mirrors the heartbeat runs list).
const fakes = makeFakes();
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
@@ -116,15 +182,7 @@ describe("ConcurrencyView", () => {
it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => {
const user = userEvent.setup();
const fakes = makeFakes({ limits: [], status: [] });
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
const providerSelect = await screen.findByLabelText("Provider");
// Choose "anthropic" from the dropdown (the list is auto-selected first).
@@ -142,15 +200,7 @@ describe("ConcurrencyView", () => {
it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => {
const user = userEvent.setup();
const fakes = makeFakes({ limits: [], status: [] });
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
const providerSelect = await screen.findByLabelText("Provider");
// A provider is auto-selected from the dropdown.
@@ -172,13 +222,7 @@ describe("ConcurrencyView", () => {
it("shows no-providers + disables the dropdown when there are no models", async () => {
const fakes = makeFakes({ limits: [], status: [] });
render(ConcurrencyView, {
- props: {
- models: [],
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
+ props: { ...props(fakes), models: [] as unknown as readonly string[] },
});
const providerSelect = await screen.findByLabelText("Provider");
@@ -189,15 +233,7 @@ describe("ConcurrencyView", () => {
it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => {
const user = userEvent.setup();
const fakes = makeFakes();
- render(ConcurrencyView, {
- props: {
- models: MODELS,
- loadLimits: fakes.loadLimits,
- saveLimit: fakes.saveLimit,
- deleteLimit: fakes.deleteLimit,
- loadStatus: fakes.loadStatus,
- },
- });
+ render(ConcurrencyView, { props: props(fakes) });
// Wait for the limits to load (unique summary) before interacting.
await screen.findByText(/1 limit configured/);
@@ -209,7 +245,7 @@ describe("ConcurrencyView", () => {
it("surfaces a load error from the limits endpoint", async () => {
const failing = {
- models: MODELS,
+ models: MODELS as unknown as readonly string[],
loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
ok: false,
error: "Concurrency service not available",
@@ -217,8 +253,191 @@ describe("ConcurrencyView", () => {
saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }),
deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }),
loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }),
+ saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }),
};
render(ConcurrencyView, { props: failing });
expect(await screen.findByText("Concurrency service not available")).toBeVisible();
});
+
+ // ── Concurrency-fixes: auto-reduce banner + cooldown editing ────────────────
+
+ it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => {
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The banner shows the backend notice verbatim + a "Restore to 4" action.
+ expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible();
+ expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ // The "Was 4, now 3." provenance line is shown.
+ expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible();
+ });
+
+ it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => {
+ const user = userEvent.setup();
+ // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced`
+ // server-side; the next status poll returns limit 4 + autoReduced===false →
+ // the banner drops.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ // Simulate the backend clearing `autoReduced` on the manual PUT: the next
+ // status load returns the restored limit with autoReduced===false.
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The restore PUT the limit back to the original (autoReducedFrom = 4).
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ // The banner is gone (no Restore button, no notice text); the status summary
+ // now reflects the restored limit (2/4 in flight).
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByRole("button", { name: /Restore to 4/ });
+ // Dismiss the banner (hide locally — the provider is still auto-reduced).
+ await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans"));
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ saveLimitError: "Concurrency service not available",
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The error surfaces INLINE in the banner (near the restore action), not
+ // only in the far-away limits section. The banner is still present (restore
+ // did not succeed) and the button re-enabled for a retry.
+ expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent(
+ "Concurrency service not available",
+ );
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled();
+ // The restore PUT was attempted.
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ });
+
+ it("clears the inline restore error on a retry that succeeds", async () => {
+ const user = userEvent.setup();
+ // First restore fails; the second succeeds (clears autoReduced). Reassigning
+ // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the
+ // rendered component, so swap it BEFORE render here.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ let attempts = 0;
+ const succeeding = fakes.saveLimit;
+ fakes.saveLimit = async (providerId, limit) => {
+ attempts++;
+ if (attempts === 1) return { ok: false, error: "Concurrency service not available" };
+ return succeeding(providerId, limit);
+ };
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+ // First attempt: inline error appears.
+ expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument();
+
+ // Retry: the error clears, the banner drops (restore succeeded).
+ await user.click(screen.getByRole("button", { name: /Restore to 4/ }));
+ await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/);
+ expect(screen.queryByTestId("restore-error-umans")).toBeNull();
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ });
+
+ it("edits the per-provider cooldown (PUT /concurrency/cooldown + reloads status)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the status card + its cooldown input (seeded with 350).
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Save cooldown for umans" }));
+
+ // The cooldown PUT fired with the new value.
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]);
+ // After save the component reloads status (which now carries cooldownMs=500).
+ expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument();
+ });
+
+ it("rejects a negative cooldown input (Save disabled — non-negative integer only)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ // 0 is valid (no cooldown); a negative is not.
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "0");
+ expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeEnabled();
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "-5");
+ expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled();
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
});