import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor"; import type { ClaudeCodeSettings } from "../../lib/types"; function renderEditor( settings: ClaudeCodeSettings | null, scope: "global" | "project" = "global", ) { const onSave = vi.fn().mockResolvedValue(undefined); render( , ); return onSave; } describe("ClaudeCodeSettingsEditor", () => { it("shows the two default-on settings as on for a project that never touched them", () => { // Claude Code's session recap and fullscreen auto-scroll are both on by // default, and the fields behind them store the *disabled* sense. A toggle // rendered straight from the field would tell every existing user their // recap is off. renderEditor(null); expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked(); expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked(); expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked(); }); it("stores the disabled sense when an inverted toggle is switched off", () => { const onSave = renderEditor(null); fireEvent.click(screen.getByRole("switch", { name: "Session recap" })); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ session_recap_disabled: true }), ); }); it("collapses back to null once every setting is at its default again", () => { // `null` is what tells the backend this project adds nothing over the // global settings, so the round trip has to land exactly back on it. const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }); fireEvent.click(screen.getByRole("switch", { name: "Session recap" })); expect(onSave).toHaveBeenCalledWith(null); }); it("offers the classic renderer as a choice distinct from automatic", () => { // Leaving `tui` unset lets Claude Code pick; pinning "default" is a // different, and previously unreachable, instruction. const onSave = renderEditor(null); const tui = screen.getByLabelText("TUI mode"); expect( Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")), ).toEqual(["", "default", "fullscreen"]); fireEvent.change(tui, { target: { value: "default" } }); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" })); }); it("offers every effort level Claude Code accepts", () => { // Verified against the shipped `claude` binary's own schema rather than // inferred: low/medium/high/xhigh/max. `max` was missing until an audit // checked externally — which is the whole weakness of this test. It can // only prove the editor agrees with this list, never that the list is the // one Claude Code reads. The same blind spot is why `effort` and // `focusMode` were confidently wrong for months. renderEditor(null); expect( Array.from( screen.getByLabelText("Effort level").querySelectorAll("option"), ).map((o) => o.getAttribute("value")), ).toEqual(["", "low", "medium", "high", "xhigh", "max"]); }); describe("project scope", () => { it("offers Global as a third state so a project can decline to have an opinion", () => { renderEditor(null, "project"); const focus = screen.getByLabelText("Focus mode"); expect( Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")), ).toEqual(["global", "off", "on"]); expect((focus as HTMLSelectElement).value).toBe("global"); }); it("stores a deliberate false so the project can turn a global On back off", () => { // The reason the field widened from boolean to boolean|null. Under the // old merge there was no project value that could produce this. const onSave = renderEditor(null, "project"); fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } }); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ focus_mode: false }), ); }); it("does not collapse a deliberate off to null", () => { // `null` means inherit. Collapsing here would silently hand the setting // straight back to the global value the user just overrode. const onSave = renderEditor(null, "project"); fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } }); expect(onSave).not.toHaveBeenCalledWith(null); }); it("round-trips the inverted fields through the disabled sense", () => { // Session recap stores `session_recap_disabled`, so choosing "off" has to // store `true` and choosing "on" has to store `false`. const onSave = renderEditor(null, "project"); const recap = screen.getByLabelText("Session recap"); fireEvent.change(recap, { target: { value: "off" } }); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ session_recap_disabled: true }), ); fireEvent.change(recap, { target: { value: "on" } }); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ session_recap_disabled: false }), ); }); it("shows a stored override rather than the inherited state", () => { renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project"); expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe( "off", ); }); /** * Auto-scroll is the second inverted field and had no project-scope test at * all — every assertion above rides on `session_recap_disabled`, so a * `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for * one of the two and pass silently for the other. It is stored as * `auto_scroll_disabled`, so every value here reads back the other way up. */ describe("auto-scroll", () => { const AUTO = "Auto-scroll"; it("starts on Global, which is not the same as on", () => { // Claude Code scrolls by default, so an inheriting project *behaves* // as on — but it has taken no position, and rendering it as "On" would // make a later global change look like it had no effect. renderEditor(null, "project"); expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global"); }); it("stores the disabled sense in both directions", () => { const onSave = renderEditor(null, "project"); const auto = screen.getByLabelText(AUTO); fireEvent.change(auto, { target: { value: "off" } }); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ auto_scroll_disabled: true }), ); fireEvent.change(auto, { target: { value: "on" } }); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ auto_scroll_disabled: false }), ); }); it("hands the setting back to the global level when Global is chosen", () => { // Back to no opinion, and with nothing else set that collapses the // whole object to `null` — the value that means "adds nothing over the // global settings". const onSave = renderEditor( { ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project", ); fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } }); expect(onSave).toHaveBeenCalledWith(null); }); it("reads a stored override back the right way up", () => { renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project"); expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off"); }); }); }); /** * The inverted fields store a *deviation*, so a stored `false` is the one * value that means "the user deliberately re-enabled the default". Nothing * asserted it: every existing test drives the `true` (turned off) direction * or the `null` (untouched) one, and both scopes would still read correctly * if the inversion were dropped from the `false` branch alone. */ describe.each([ ["session_recap_disabled", "Session recap"] as const, ["auto_scroll_disabled", "Auto-scroll"] as const, ])("a stored false on %s", (key, label) => { it("reads as On at project scope, not as Off", () => { renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project"); expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on"); }); it("reads as on at global scope, where the control is a switch", () => { renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }); expect(screen.getByRole("switch", { name: label })).toBeChecked(); }); }); /** * A settings object with nothing set at this level arrives as `{}`: the Rust * struct skips serialising a field it has no value for, which is what keeps * an older binary able to parse `projects.json` after a downgrade. It is also * the exact shape a project stored before the fields were widened is read * back as — every one of its `false`s meant "unset" — so reading absent as * "off" would show a switch the user never touched as a deliberate choice. */ it("reads an absent field as Global rather than as Off", () => { renderEditor({} as ClaudeCodeSettings, "project"); expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global"); expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global"); }); it("still collapses to null when an absent-field object is edited back", () => { const onSave = renderEditor({} as ClaudeCodeSettings, "global"); // Off and straight back on: the round trip has to land on `null`, or an // untouched global stops being indistinguishable from one never opened. fireEvent.click(screen.getByRole("switch", { name: "Session recap" })); fireEvent.click(screen.getByRole("switch", { name: "Session recap" })); expect(onSave).toHaveBeenLastCalledWith(null); }); });