/** * Interactive tests for the settings popup's text/number rows: the draft is * local state committed on blur through the parent's onCommit; the parent's * canonical return is adopted (clamped numbers, stored value for invalid * input), and the row remounts when the committed pref value changes (a * failed commit reverts prefs → the stored value reappears). * * Rendered with createRoot + act() in jsdom (the SSR specs stay in * side-card-section.spec.tsx; this file exercises the event paths). */ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' import { createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' // The act() environment flag (React 18.2 reads it before flushing effects). ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true import type { SidebarSettingToggle } from '../src/client/service.ts' import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx' import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' /** Render the rows into a detached container under React's act(). */ function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } { const container = document.createElement('div') document.body.append(container) const root: Root = createRoot(container) act(() => { root.render(node) }) return { container, rerender: (next) => { act(() => { root.render(next) }) }, unmount: () => { act(() => { root.unmount() }) container.remove() }, } } /** Type into an input and commit it via blur (React 18: input event + * focusout). The native setter bypasses React's value tracker so the * change is actually seen. */ function typeAndBlur(input: HTMLInputElement, value: string): void { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! act(() => { setter.call(input, value) input.dispatchEvent(new Event('input', { bubbles: true })) input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) }) } const prefs = { ...SIDEBAR_PREFS_DEFAULTS } describe('FeatureSettingsRows typed rows (interactive)', () => { it('commits the raw text on blur and adopts the canonical return', () => { const commits: Array<[string, string]> = [] const toggle: SidebarSettingToggle = { key: 'terminalFontFamily', type: 'text', title: () => 'Font family', } const { container, unmount } = mount(createElement(FeatureSettingsRows, { toggles: [toggle], prefs, onToggle: () => {}, onCommit: (t, raw) => { commits.push([t.key, raw]) return raw }, })) const input = container.querySelector('input')! typeAndBlur(input, 'Monaco') expect(commits).toEqual([['terminalFontFamily', 'Monaco']]) // The canonical return is adopted into the draft. expect(input.value).toBe('Monaco') unmount() }) it('clamps numbers into the declared bounds on commit', () => { const commits: Array<[string, number]> = [] const toggle: SidebarSettingToggle = { key: 'terminalFontSize', type: 'number', title: () => 'Font size', min: 9, max: 32, } const { container, unmount } = mount(createElement(FeatureSettingsRows, { toggles: [toggle], prefs: { ...prefs, terminalFontSize: 13 }, onToggle: () => {}, onCommit: (t, raw) => { const parsed = Number(raw) const clamped = Math.min(32, Math.max(9, Math.round(parsed))) commits.push([t.key, clamped]) return String(clamped) }, })) const input = container.querySelector('input')! typeAndBlur(input, '40') expect(commits).toEqual([['terminalFontSize', 32]]) expect(input.value).toBe('32') unmount() }) it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => { const commits: Array<[string, number]> = [] const toggle: SidebarSettingToggle = { key: 'terminalFontSize', type: 'number', title: () => 'Font size', min: 9, max: 32, } const { container, unmount } = mount(createElement(FeatureSettingsRows, { toggles: [toggle], prefs: { ...prefs, terminalFontSize: 13 }, onToggle: () => {}, // The parent mirrors the real handler: an emptied number parses to 0 // and clamps into the bounds (a browser number input never holds a // non-numeric string — the draft can only be empty or numeric). onCommit: (t, raw) => { const clamped = Math.min(32, Math.max(9, Math.round(Number(raw)))) commits.push([t.key, clamped]) return String(clamped) }, })) const input = container.querySelector('input')! typeAndBlur(input, '') expect(commits).toEqual([['terminalFontSize', 9]]) expect(input.value).toBe('9') unmount() }) it('reverts the draft to the committed value after a failed commit reverts prefs', () => { const toggle: SidebarSettingToggle = { key: 'terminalFontFamily', type: 'text', title: () => 'Font family', } // The parent mirrors the real handler: the optimistic commit adopts the // typed value, and a FAILED write reverts prefs to the stored one. let prefsNow = { ...prefs, terminalFontFamily: 'Old' } const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, { toggles: [toggle], prefs: prefsNow, onToggle: () => {}, onCommit: (_t, raw) => raw, })) const input = container.querySelector('input')! typeAndBlur(input, 'New') // Optimistic commit: the draft adopts the typed value. expect(input.value).toBe('New') // The optimistic pref lands (parent state): the key changes, the row // remounts with the same value — no visible reset while editing. prefsNow = { ...prefsNow, terminalFontFamily: 'New' } rerender(createElement(FeatureSettingsRows, { toggles: [toggle], prefs: prefsNow, onToggle: () => {}, onCommit: (_t, raw) => raw, })) expect(container.querySelector('input')!.value).toBe('New') // The write fails: prefs revert to the stored value and the row // remounts with it (the stale draft is gone). prefsNow = { ...prefsNow, terminalFontFamily: 'Old' } rerender(createElement(FeatureSettingsRows, { toggles: [toggle], prefs: prefsNow, onToggle: () => {}, onCommit: (_t, raw) => raw, })) expect(container.querySelector('input')!.value).toBe('Old') unmount() }) })