diff --git a/changelog.d/fixes/15613-compression-engines-partial-write.md b/changelog.d/fixes/15613-compression-engines-partial-write.md new file mode 100644 index 000000000000..efdc459aec0a --- /dev/null +++ b/changelog.d/fixes/15613-compression-engines-partial-write.md @@ -0,0 +1 @@ +- **fix(compression):** the compression engines map now merges by engine id on `PUT /api/settings/compression` instead of storing the map whole, and the Omniglyph page and context settings panel send only the engine they changed — so a toggle on one page no longer wipes what the other page (or a failed settings GET) never loaded. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 10c7deeaf7fa..49f7f4415bb9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2628,6 +2628,11 @@ paths: put: tags: [Compression] summary: Update global compression settings + description: >- + Partial update: only the sent top-level keys change. `engines` merges by engine id — + an entry overwrites only the fields it sends, engines left out are kept as-is, and + until an engines row is stored the merge base is the map derived from the legacy + per-engine settings. security: - ManagementSessionAuth: [] requestBody: @@ -2648,6 +2653,19 @@ paths: autoTriggerTokens: type: integer minimum: 0 + engines: + type: object + description: >- + Per-engine toggles, merged by engine id into the stored map. Each entry + needs `enabled`; `level` survives an entry that omits it. + additionalProperties: + type: object + properties: + enabled: + type: boolean + level: + type: string + required: [enabled] rtkConfig: type: object additionalProperties: true diff --git a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx index 5a14d801e397..78f904219668 100644 --- a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx @@ -9,7 +9,7 @@ // // Card/Toggle are imported from their direct module paths (not the @/shared/components // barrel) — the barrel pulls a Node-only module that hangs vitest/jsdom. -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import Card from "@/shared/components/Card"; import Toggle from "@/shared/components/Toggle"; @@ -20,8 +20,6 @@ interface CompressionConfigLite { omniglyph?: { profile?: string }; } -type EngineMap = Record; - /** Perfis do pacote, na ordem do mais permissivo ao mais restrito. O primeiro é * o default: a política que os recibos publicados mediram. */ const PROFILES = [ @@ -243,20 +241,18 @@ function EnableCard(props: { } export default function OmniglyphContextPageClient() { - const [engines, setEngines] = useState({}); const [enabled, setEnabled] = useState(false); const [profile, setProfile] = useState("aggressive"); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<"" | "saved" | "error">(""); + const saveGenRef = useRef(0); useEffect(() => { fetch("/api/settings/compression") .then((r) => (r.ok ? r.json() : null)) .then((data: CompressionConfigLite | null) => { - const e = data?.engines ?? {}; - setEngines(e); - setEnabled(e.omniglyph?.enabled === true); + setEnabled(data?.engines?.omniglyph?.enabled === true); const stored = data?.omniglyph?.profile; if (PROFILES.some((p) => p.id === stored)) setProfile(stored as ProfileId); }) @@ -264,62 +260,47 @@ export default function OmniglyphContextPageClient() { .finally(() => setLoading(false)); }, []); - // Persist the FULL engines map (the store keeps it as one JSON row — a partial patch - // of a single engine would drop the others). Mirrors CompressionPanel.setEngine. - const toggle = async (next: boolean) => { - setEnabled(next); - const nextEngines: EngineMap = { - ...engines, - omniglyph: { ...(engines.omniglyph ?? { enabled: false }), enabled: next }, - }; - setEngines(nextEngines); + // PUT one settings patch; a failed save runs `rollback`. The saved status clears after 2s + // unless a later save has started since, so it never clears that save's error. + const save = async (body: Record, rollback: () => void) => { + const gen = ++saveGenRef.current; setSaving(true); setStatus(""); + let ok = false; try { const res = await fetch("/api/settings/compression", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ engines: nextEngines }), + body: JSON.stringify(body), }); - if (res.ok) { - setStatus("saved"); - setTimeout(() => setStatus(""), 2000); - } else { - setStatus("error"); - } + ok = res.ok; } catch { + // A network failure is a failed save. + } + setSaving(false); + if (!ok) { + rollback(); setStatus("error"); - } finally { - setSaving(false); + return; } + setStatus("saved"); + setTimeout(() => { + if (gen === saveGenRef.current) setStatus(""); + }, 2000); + }; + + // The server merges `engines` by engine id, so the toggle sends only the omniglyph entry. + const toggle = (next: boolean) => { + const previous = enabled; + setEnabled(next); + void save({ engines: { omniglyph: { enabled: next } } }, () => setEnabled(previous)); }; - // O perfil vive na config do engine (não no mapa `engines`), então é um PATCH - // próprio — misturá-lo no payload do toggle reescreveria o mapa inteiro. - const changeProfile = async (next: ProfileId) => { + // O perfil vive na config do engine (não no mapa `engines`), então vai num PUT próprio. + const changeProfile = (next: ProfileId) => { const previous = profile; setProfile(next); - setSaving(true); - setStatus(""); - try { - const res = await fetch("/api/settings/compression", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ omniglyph: { profile: next } }), - }); - if (res.ok) { - setStatus("saved"); - setTimeout(() => setStatus(""), 2000); - } else { - setProfile(previous); - setStatus("error"); - } - } catch { - setProfile(previous); - setStatus("error"); - } finally { - setSaving(false); - } + void save({ omniglyph: { profile: next } }, () => setProfile(previous)); }; return ( diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index 34af4b20ef89..b0f6a881476a 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -266,8 +266,8 @@ export default function CompressionPanel() { .catch(() => {}); }, []); - // Persist a merge-patch. The DB persists `engines` as one whole row, so callers that - // touch an engine pass the full engines map to avoid dropping the other engines. + // Persist a merge-patch. The server merges `engines` by engine id, so a caller that + // touches an engine passes only that engine, merged into the local map here. // Generation + configRef: a later in-flight save must not let an older failure // roll back a newer optimistic (or already-acked) state. const save = async (updates: Partial) => { @@ -276,6 +276,7 @@ export default function CompressionPanel() { const next: CompressionConfig = { ...previous, ...updates, + ...(updates.engines ? { engines: { ...previous.engines, ...updates.engines } } : {}), ...(updates.contextBudget ? { contextBudget: { @@ -327,12 +328,7 @@ export default function CompressionPanel() { }; const setEngine = (id: string, patch: Partial) => { - const engines = { - ...config.engines, - [id]: { ...(config.engines[id] ?? { enabled: false }), ...patch }, - }; - // Send the full engines map — the persistence layer stores it as one JSON row. - save({ engines }); + save({ engines: { [id]: { ...(config.engines[id] ?? { enabled: false }), ...patch } } }); }; const toggleGuidance = (id: string) => { diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index f95cb8813198..2ab549ea2463 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -531,6 +531,33 @@ function sanitizeEnginesForWrite(value: unknown): Record { return out; } +// Partial engines writes replace the whole JSON row, and the read path turns every id missing +// from a stored row off. Merge each written entry over the stored entry, field by field. While +// no usable row exists, merge over `current`, the map the read path derives from legacy settings. +function mergeEnginesForWrite( + db: ReturnType, + value: unknown, + current: Record +): Record { + const existingRow = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(NAMESPACE, "engines") as { value: unknown } | undefined; + // getCompressionSettings skips non-text (BLOB) rows, so treat them as absent here too. + const stored = + typeof existingRow?.value === "string" + ? parseStoredEnginesMap(parseJsonSafe(existingRow.value)) + : null; + const base = stored ?? current; + const incoming = toRecord(value); + const merged: JsonRecord = {}; + for (const id of ENGINE_IDS) { + merged[id] = Object.hasOwn(incoming, id) + ? { ...base[id], ...toRecord(incoming[id]) } + : base[id]; + } + return sanitizeEnginesForWrite(merged); +} + // Partial lite writes replace the whole JSON row. Keep a stored cap unless the // caller sends maxToolLength: null (clear) or a new in-range integer. function mergeLiteSettingsForWrite( @@ -914,6 +941,10 @@ export async function updateCompressionSettings( const insert = db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" ); + // The engines map the read path returns now: the merge base for an engines write while no + // engines row exists yet. + const currentEngines: Record = + updates.engines === undefined ? {} : (await getCompressionSettings()).engines; const tx = db.transaction(() => { for (const [key, value] of Object.entries(updates)) { @@ -921,7 +952,7 @@ export async function updateCompressionSettings( // Persist the engines map as ONE sanitized JSON row so the read path always gets // well-formed { enabled, level? } toggles for known engine ids. if (key === "engines") { - insert.run(NAMESPACE, key, JSON.stringify(sanitizeEnginesForWrite(value))); + insert.run(NAMESPACE, key, JSON.stringify(mergeEnginesForWrite(db, value, currentEngines))); continue; } if (key === "lite") { diff --git a/tests/unit/api/context-combos-default-route.test.ts b/tests/unit/api/context-combos-default-route.test.ts index 67407075afc2..29e30c153852 100644 --- a/tests/unit/api/context-combos-default-route.test.ts +++ b/tests/unit/api/context-combos-default-route.test.ts @@ -18,6 +18,7 @@ import path from "node:path"; import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; import { deriveDefaultPlan } from "@omniroute/open-sse/services/compression/deriveDefaultPlan.ts"; +import { ENGINE_IDS } from "@omniroute/open-sse/services/compression/engineCatalog.ts"; // ─── isolated temp DB ───────────────────────────────────────────────────────── @@ -45,6 +46,14 @@ async function setupAuth(): Promise { }); } +// An engines write merges over the engines the install already has, so a test that needs only +// some engines on writes every engine id, with the rest off. +function onlyEngines( + enabled: Record +): Record { + return Object.fromEntries(ENGINE_IDS.map((id) => [id, enabled[id] ?? { enabled: false }])); +} + // ─── lifecycle ──────────────────────────────────────────────────────────────── test.beforeEach(async () => { @@ -66,7 +75,7 @@ test.after(() => { test("GET /api/context/combos/default returns the derived default plan (single-mode caveman)", async () => { await compressionDb.updateCompressionSettings({ enabled: true, - engines: { caveman: { enabled: true, level: "full" } }, + engines: onlyEngines({ caveman: { enabled: true, level: "full" } }), }); const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default"); @@ -88,10 +97,10 @@ test("GET /api/context/combos/default returns the derived default plan (single-m test("GET /api/context/combos/default returns the derived stacked pipeline (reflects enabled engines)", async () => { await compressionDb.updateCompressionSettings({ enabled: true, - engines: { + engines: onlyEngines({ caveman: { enabled: true, level: "full" }, headroom: { enabled: true }, - }, + }), }); const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default"); diff --git a/tests/unit/compression/engines-partial-write.test.ts b/tests/unit/compression/engines-partial-write.test.ts new file mode 100644 index 000000000000..8bdcf8ed7ecc --- /dev/null +++ b/tests/unit/compression/engines-partial-write.test.ts @@ -0,0 +1,127 @@ +// Two dashboard pages write the `engines` map: the Omniglyph page and the context settings +// panel. A write that carries only the engine it changed must leave every other engine as it +// was, or a toggle on one page turns off what the other page (or a failed GET) never loaded. +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-engines-partial-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = + await import("../../../src/lib/db/compression.ts"); + +type SettingsUpdate = Parameters[0]; + +beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +afterEach(() => { + core.resetDbInstance(); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +describe("updateCompressionSettings engines partial writes", () => { + it("keeps the other stored engines when a write carries one engine", async () => { + await updateCompressionSettings({ + engines: { + rtk: { enabled: true, level: "standard" }, + caveman: { enabled: true, level: "full" }, + }, + }); + core.resetDbInstance(); + + await updateCompressionSettings({ engines: { omniglyph: { enabled: true } } }); + core.resetDbInstance(); + + const { engines, enginesExplicit } = await getCompressionSettings(); + assert.deepEqual(engines.rtk, { enabled: true, level: "standard" }); + assert.deepEqual(engines.caveman, { enabled: true, level: "full" }); + assert.deepEqual(engines.omniglyph, { enabled: true }); + assert.equal(enginesExplicit, true); + }); + + it("keeps a toggle made on one page when the other page writes a different engine", async () => { + await updateCompressionSettings({ engines: { rtk: { enabled: true } } }); + // The Omniglyph page turns its engine on after the panel loaded the map... + await updateCompressionSettings({ engines: { omniglyph: { enabled: true } } }); + // ...then the panel turns caveman on. + await updateCompressionSettings({ engines: { caveman: { enabled: true } } }); + core.resetDbInstance(); + + const { engines } = await getCompressionSettings(); + assert.equal(engines.omniglyph.enabled, true); + assert.equal(engines.rtk.enabled, true); + assert.equal(engines.caveman.enabled, true); + }); + + it("keeps a stored engine level when a write only sets enabled", async () => { + await updateCompressionSettings({ engines: { caveman: { enabled: true, level: "ultra" } } }); + core.resetDbInstance(); + + await updateCompressionSettings({ engines: { caveman: { enabled: false } } }); + core.resetDbInstance(); + + const { engines } = await getCompressionSettings(); + assert.deepEqual(engines.caveman, { enabled: false, level: "ultra" }); + }); + + it("keeps the engines derived from legacy settings on the first engines write", async () => { + await updateCompressionSettings({ cavemanConfig: { enabled: true } } as SettingsUpdate); + core.resetDbInstance(); + const before = await getCompressionSettings(); + assert.equal(before.enginesExplicit, false); + assert.equal(before.engines.caveman.enabled, true); + core.resetDbInstance(); + + await updateCompressionSettings({ engines: { omniglyph: { enabled: true } } }); + core.resetDbInstance(); + + const after = await getCompressionSettings(); + assert.equal(after.enginesExplicit, true); + assert.equal(after.engines.caveman.enabled, true); + assert.equal(after.engines.omniglyph.enabled, true); + }); + + it("treats a stored non-text engines row as absent and merges over the legacy-derived map", async () => { + await updateCompressionSettings({ cavemanConfig: { enabled: true } } as SettingsUpdate); + core.resetDbInstance(); + + // A BLOB in the engines key: the read path skips non-text rows, so the write path must + // not pick the row up as a merge base either and falls back to the derived map. + core + .getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run("compression", "engines", Buffer.from([0x00, 0xff])); + core.resetDbInstance(); + + const before = await getCompressionSettings(); + assert.equal(before.enginesExplicit, false); + assert.equal(before.engines.caveman.enabled, true); + core.resetDbInstance(); + + await updateCompressionSettings({ engines: { omniglyph: { enabled: true } } }); + core.resetDbInstance(); + + const after = await getCompressionSettings(); + assert.equal(after.engines.caveman.enabled, true); + assert.equal(after.engines.omniglyph.enabled, true); + assert.equal(after.enginesExplicit, true); + }); +}); diff --git a/tests/unit/ui/compressionPanel.test.tsx b/tests/unit/ui/compressionPanel.test.tsx index d83a0672baac..a12a59b161d3 100644 --- a/tests/unit/ui/compressionPanel.test.tsx +++ b/tests/unit/ui/compressionPanel.test.tsx @@ -194,8 +194,8 @@ describe("CompressionPanel", () => { .pop(); expect(lastEngines).toBeTruthy(); expect(lastEngines!.caveman.enabled).toBe(true); - // Full engines map is sent (whole-row persistence), so rtk is not dropped. - expect(lastEngines!.rtk.enabled).toBe(true); + // The server merges engines by id, so only the toggled engine is sent. + expect(lastEngines!.rtk).toBeUndefined(); }); it("derived-pipeline preview reflects the enabled engines", async () => { diff --git a/tests/unit/ui/compressionPanelEnginesPartialWrite.test.tsx b/tests/unit/ui/compressionPanelEnginesPartialWrite.test.tsx new file mode 100644 index 000000000000..e2d804d9d7cd --- /dev/null +++ b/tests/unit/ui/compressionPanelEnginesPartialWrite.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +// The server merges the `engines` map by engine id, so the context settings panel sends only +// the engine a control changed. A whole-map PUT built from the panel's copy would overwrite +// engines another page (the Omniglyph page, the MCP tool) changed after the panel loaded. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key} ${Object.values(values).join(" ")}` : key, + useLocale: () => "en", +})); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => { + root.render(ui); + }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +function setupFetchMock(): { settingsPuts: Array> } { + const settingsPuts: Array> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const initialConfig = { + enabled: true, + engines: { rtk: { enabled: true, level: "standard" }, caveman: { enabled: false } }, + activeComboId: null, + }; + + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) { + return json({ enabled: true, maxTextChars: 50000 }); + } + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")); + settingsPuts.push(body); + return json({ ...initialConfig, ...body }); + } + return json(initialConfig); + } + return json({}, 404); + } + ); + return { settingsPuts }; +} + +async function mountPanel(): Promise { + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + return container; +} + +function engineSwitch(container: HTMLElement, id: string): HTMLButtonElement { + const button = container.querySelector( + `[data-testid="engine-toggle-${id}"] button` + ) as HTMLButtonElement | null; + expect(button, `${id} toggle must exist`).toBeTruthy(); + return button!; +} + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.click(); + }); + await flush(); +} + +describe("CompressionPanel engines partial writes", () => { + it("toggling one engine PUTs only that engine and keeps the others on screen", async () => { + const { settingsPuts } = setupFetchMock(); + const container = await mountPanel(); + + await click(engineSwitch(container, "caveman")); + + expect(settingsPuts).toEqual([{ engines: { caveman: { enabled: true } } }]); + expect(engineSwitch(container, "caveman").getAttribute("aria-checked")).toBe("true"); + expect(engineSwitch(container, "rtk").getAttribute("aria-checked")).toBe("true"); + }); + + it("toggling two engines in turn sends each one alone and keeps both on screen", async () => { + const { settingsPuts } = setupFetchMock(); + const container = await mountPanel(); + + await click(engineSwitch(container, "caveman")); + await click(engineSwitch(container, "lite")); + + expect(settingsPuts).toEqual([ + { engines: { caveman: { enabled: true } } }, + { engines: { lite: { enabled: true } } }, + ]); + for (const id of ["rtk", "caveman", "lite"]) { + expect(engineSwitch(container, id).getAttribute("aria-checked"), id).toBe("true"); + } + }); + + it("changing an engine level PUTs that engine with its current switch state", async () => { + const { settingsPuts } = setupFetchMock(); + const container = await mountPanel(); + const select = container.querySelector( + `[data-testid="engine-row-rtk"] select` + ) as HTMLSelectElement | null; + expect(select, "rtk level select must exist").toBeTruthy(); + + await act(async () => { + select!.value = "aggressive"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + expect(settingsPuts).toEqual([{ engines: { rtk: { enabled: true, level: "aggressive" } } }]); + }); +}); diff --git a/tests/unit/ui/omniglyphContextPage.test.tsx b/tests/unit/ui/omniglyphContextPage.test.tsx index d3893947b94d..d216f9267816 100644 --- a/tests/unit/ui/omniglyphContextPage.test.tsx +++ b/tests/unit/ui/omniglyphContextPage.test.tsx @@ -51,16 +51,29 @@ interface CapturedPut { body: Record; } -function setupFetchMock(): { puts: CapturedPut[] } { +// putStatus(n) sets the HTTP status of the n-th PUT (1-based); every PUT succeeds by default. +// rejectPuts makes every PUT throw instead, the network-failure path. +function setupFetchMock( + options: { + putStatus?: (n: number) => number; + rejectPuts?: boolean; + initialEngines?: Record; + } = {} +): { + puts: CapturedPut[]; +} { const puts: CapturedPut[] = []; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); - // omniglyph absent from `engines` initially → disabled; rtk/caveman present so we can - // assert they SURVIVE the PUT (the store persists the whole map as one row). + // omniglyph is absent from `engines`, so the switch starts off; rtk and caveman are present + // so a test can check that a toggle PUT leaves them out. const initialConfig = { enabled: true, - engines: { rtk: { enabled: true, level: "standard" }, caveman: { enabled: false } }, + engines: options.initialEngines ?? { + rtk: { enabled: true, level: "standard" }, + caveman: { enabled: false }, + }, }; vi.spyOn(globalThis, "fetch").mockImplementation( @@ -69,9 +82,11 @@ function setupFetchMock(): { puts: CapturedPut[] } { const method = (init?.method ?? "GET").toUpperCase(); if (url.includes("/api/settings/compression")) { if (method === "PUT") { + if (options.rejectPuts) throw new TypeError("fetch failed"); const body = JSON.parse(String(init?.body ?? "{}")); puts.push({ url, body }); - return json({ ...initialConfig, ...body }); + const status = options.putStatus?.(puts.length) ?? 200; + return status === 200 ? json({ ...initialConfig, ...body }) : json({}, status); } return json(initialConfig); } @@ -81,13 +96,38 @@ function setupFetchMock(): { puts: CapturedPut[] } { return { puts }; } +async function mountPage(): Promise { + const { default: Page } = + await import("../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + return container; +} + +function enableToggle(container: HTMLElement): HTMLButtonElement { + const toggle = container.querySelector( + '[data-testid="omniglyph-enable-toggle"] button' + ) as HTMLButtonElement | null; + expect(toggle, "enable toggle button must exist").toBeTruthy(); + return toggle!; +} + +async function click(button: HTMLButtonElement) { + await act(async () => { + button.click(); + }); + await flush(); +} + // ── Tests ────────────────────────────────────────────────────────────────────── describe("OmniglyphContextPage", () => { it("renders the four sections with the measured numbers and the real render", async () => { setupFetchMock(); - const { default: Page } = await import( - "../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient" - ); + const { default: Page } = + await import("../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient"); let container!: HTMLElement; await act(async () => { container = mount(); @@ -117,30 +157,83 @@ describe("OmniglyphContextPage", () => { expect(container.querySelector('[data-testid="omniglyph-enable-toggle"]')).toBeTruthy(); }); - it("enabling the engine PUTs the full engines map with omniglyph on, preserving the others", async () => { + it("enabling the engine PUTs only the omniglyph entry", async () => { const { puts } = setupFetchMock(); - const { default: Page } = await import( - "../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient" - ); - let container!: HTMLElement; - await act(async () => { - container = mount(); - }); - await flush(); + const container = await mountPage(); - const toggle = container.querySelector('[data-testid="omniglyph-enable-toggle"] button') as HTMLButtonElement | null; - expect(toggle, "enable toggle button must exist").toBeTruthy(); - await act(async () => { - toggle!.click(); + await click(enableToggle(container)); + + expect(puts.length).toBe(1); + // The server merges engines by id, so the page sends only the engine it changed and + // cannot overwrite engines another page changed after this one loaded. + expect(puts[0]!.body).toEqual({ engines: { omniglyph: { enabled: true } } }); + }); + + it("disabling the engine PUTs only the omniglyph entry, off", async () => { + const { puts } = setupFetchMock({ + initialEngines: { + omniglyph: { enabled: true, level: "standard" }, + rtk: { enabled: true }, + }, }); - await flush(); + const container = await mountPage(); + const toggle = enableToggle(container); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + await click(toggle); expect(puts.length).toBe(1); - const engines = puts[0]!.body.engines as Record; - expect(engines.omniglyph).toEqual({ enabled: true }); - // The other engines must survive the whole-map PUT. - expect(engines.rtk?.enabled).toBe(true); - expect(engines.caveman?.enabled).toBe(false); + // The off toggle also sends only its own entry; a stored level survives server-side. + expect(puts[0]!.body).toEqual({ engines: { omniglyph: { enabled: false } } }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + + it("a rejected fetch is a failed save: the switch rolls back and the error shows", async () => { + const { puts } = setupFetchMock({ rejectPuts: true }); + const container = await mountPage(); + const toggle = enableToggle(container); + + await click(toggle); + + expect(puts.length).toBe(0); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(container.textContent).toContain("Could not save."); + }); + + it("a failed save puts the switch back and shows the error", async () => { + const { puts } = setupFetchMock({ putStatus: () => 500 }); + const container = await mountPage(); + const toggle = enableToggle(container); + + await click(toggle); + + expect(puts.length).toBe(1); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(container.textContent).toContain("Could not save."); + }); + + it("a timer left by an earlier save does not clear a later save error", async () => { + vi.useFakeTimers(); + try { + const { puts } = setupFetchMock({ putStatus: (n) => (n === 1 ? 200 : 500) }); + const container = await mountPage(); + const toggle = enableToggle(container); + + await click(toggle); + expect(container.textContent).toContain("Saved."); + + await click(toggle); + expect(puts.length).toBe(2); + expect(container.textContent).toContain("Could not save."); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + await flush(); + expect(container.textContent).toContain("Could not save."); + } finally { + vi.useRealTimers(); + } }); it("carrega o perfil salvo e faz PATCH só do perfil, sem reescrever o mapa de engines", async () => { @@ -170,9 +263,8 @@ describe("OmniglyphContextPage", () => { } ); - const { default: Page } = await import( - "../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient" - ); + const { default: Page } = + await import("../../../src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient"); let container!: HTMLElement; await act(async () => { container = mount();