diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 13:18:25 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 14:41:47 +0900 |
| commit | 0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch) | |
| tree | b198da2719987ffd58e9c2633fc36684cf2ad316 /src/features/concurrency/ui/ConcurrencyView.test.ts | |
| parent | a59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff) | |
| download | dispatch-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.ts | 339 |
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); + }); }); |
