diff --git a/src/App.test.tsx b/src/App.test.tsx index 99d9b376..d60c9219 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,14 +2,10 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import { api, browserState } from "./api"; -import type { AppSettings, BluetoothState, SwitchProfile } from "./types"; +import type { BluetoothState, SwitchProfile } from "./types"; const defaultBrowserSettings = structuredClone(browserState.settings); -function stateWithSettings(settings: AppSettings) { - return { ...structuredClone(browserState), settings: structuredClone(settings) }; -} - describe("Switchify PC shell", () => { beforeEach(() => { browserState.settings = structuredClone(defaultBrowserSettings); @@ -110,52 +106,6 @@ describe("Switchify PC shell", () => { expect(screen.queryByText("Internal runtime activity")).not.toBeInTheDocument(); }); - it("shows update progress and exposes cancellation in Settings", async () => { - browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; - const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState)); - render(); - await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - - expect(screen.getByText("Downloading Switchify PC 1.0.0-beta.2…")).toBeInTheDocument(); - expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50"); - expect(document.querySelector(".update-controls > span")).toHaveTextContent("25%"); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it("offers the correct retry action after a failure", async () => { - browserState.updater = { status: "failed", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: "Download failed", retryAction: "download" }; - const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); - render(); - await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByRole("alert")).toHaveTextContent("Download failed"); - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - expect(download).toHaveBeenCalledOnce(); - }); - - it("offers installation and restart when a download is ready", async () => { - browserState.updater = { status: "readyToInstall", version: "1.0.0-beta.2", downloadedBytes: 200, totalBytes: 200, error: null, retryAction: null }; - const install = vi.spyOn(api, "installUpdate").mockResolvedValue(structuredClone(browserState)); - render(); - await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); - expect(install).toHaveBeenCalledOnce(); - }); - - it("retries a cancelled download from the beginning", async () => { - browserState.updater = { status: "cancelled", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: "download" }; - const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); - render(); - await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready."); - fireEvent.click(screen.getByRole("button", { name: "Retry download" })); - expect(download).toHaveBeenCalledOnce(); - }); - it("routes tray navigation without discarding a dirty profile silently", async () => { let navigate: ((target: "home" | "settings" | "profiles") => void) | undefined; vi.spyOn(api, "onNavigateRequested").mockImplementation(async (handler) => { @@ -203,28 +153,6 @@ describe("Switchify PC shell", () => { expect(stop).toHaveBeenCalledOnce(); }); - it("reports manual update-check failures in Settings", async () => { - const checkForUpdates = vi.spyOn(api, "checkForUpdates").mockRejectedValue(new Error("Update service unavailable")); - render(); - await screen.findByRole("heading", { name: "Switchify PC" }); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); - expect(await screen.findByRole("alert")).toHaveTextContent("Update service unavailable"); - checkForUpdates.mockRestore(); - }); - - it.each([ - ["checking", "Checking for updates…", "Checking"], - ["current", "Switchify PC is up to date.", "Check for updates"], - ] as const)("keeps the %s update state visible in Settings", async (status, description, action) => { - browserState.updater = { status, version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - expect(screen.getByText(description)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: action })).toBeInTheDocument(); - }); - it.each([ ["available", "Switchify PC 1.0.0-beta.2 is available.", "View update"], ["downloading", "Downloading Switchify PC 1.0.0-beta.2 — 25%", "View progress"], @@ -393,236 +321,6 @@ describe("Switchify PC shell", () => { expect(screen.getByRole("heading", { name: "Waiting for an Android device" })).toBeInTheDocument(); }); - it("exposes key repeat settings and disables them with the toggle", async () => { - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" }); - expect(toggle).toBeChecked(); - - const delay = screen.getByRole("group", { name: "Delay before repeating" }); - expect(delay).not.toBeDisabled(); - for (const label of ["None", "Short", "Medium", "Long"]) { - expect(within(delay).getByRole("button", { name: label })).toBeInTheDocument(); - } - expect(within(delay).getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true"); - - const interval = screen.getByRole("group", { name: "Key interval" }); - expect(within(interval).getByRole("button", { name: "0.25s" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(within(interval).getByRole("button", { name: "0.5s" })); - expect(within(interval).getByRole("button", { name: "0.5s" })).toHaveAttribute("aria-pressed", "true"); - - fireEvent.click(within(delay).getByRole("button", { name: "None" })); - expect(within(delay).getByRole("button", { name: "None" })).toHaveAttribute("aria-pressed", "true"); - - // Turning the feature off must disable its cadence controls, matching the - // mouse repeat and dwell blocks. - fireEvent.click(toggle); - expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled(); - expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled(); - }); - - it("opens settings with accessible native controls", async () => { - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument(); - expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "100% pointer speed" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked(); - expect(screen.getByRole("group", { name: "Movement acceleration" })).not.toBeDisabled(); - expect(screen.getAllByRole("button", { name: "Medium" })[0]).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); - expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByText("2.5")).toBeInTheDocument(); - fireEvent.change(screen.getByRole("combobox", { name: "Exact pointer speed" }), { target: { value: "125" } }); - expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("125"); - expect(screen.getByText("5.5")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" })); - expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled(); - const dwell = screen.getByRole("checkbox", { name: "Dwell to click" }); - expect(dwell).not.toBeChecked(); - expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled(); - fireEvent.click(dwell); - const dwellDelay = screen.getByRole("group", { name: "Dwell delay" }); - expect(dwellDelay).not.toBeDisabled(); - expect(within(dwellDelay).getAllByRole("button")).toHaveLength(10); - for (const label of ["0.5s", "1s", "1.5s", "2s", "3s", "4s", "5s", "6s", "7s", "8s"]) { - expect(within(dwellDelay).getByRole("button", { name: label })).toBeInTheDocument(); - } - expect(within(dwellDelay).getByRole("button", { name: "1s" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(within(dwellDelay).getByRole("button", { name: "1.5s" })); - expect(within(dwellDelay).getByRole("button", { name: "1.5s" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByText(/After Android pointer movement stops/)).toBeInTheDocument(); - expect(screen.getByRole("checkbox", { name: "Show cursor overlay" })).toBeChecked(); - expect(screen.getByRole("button", { name: "While controlling" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByText(/On input hides shortly/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "On input" })); - expect(screen.getByRole("button", { name: "On input" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getAllByRole("button", { name: "Medium" }).at(-1)).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("radio", { name: "Red" })).toBeChecked(); - fireEvent.click(screen.getByRole("checkbox", { name: "Show crosshairs" })); - expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeChecked(); - fireEvent.click(screen.getByRole("checkbox", { name: "Show cursor overlay" })); - expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeDisabled(); - }); - - it("automatically saves a settings change", async () => { - const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); - - await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ shareDiagnostics: true }))); - expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); - }); - - it("persists dwell enablement and delay through automatic settings saves", async () => { - const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" })); - await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 1000 }))); - const dwellDelay = screen.getByRole("group", { name: "Dwell delay" }); - fireEvent.click(within(dwellDelay).getByRole("button", { name: "8s" })); - await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 8000 }))); - expect(within(dwellDelay).getByRole("button", { name: "8s" })).toHaveAttribute("aria-pressed", "true"); - }); - - it("restores confirmed dwell settings when automatic saving fails", async () => { - vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable")); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" })); - - expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable"); - expect(screen.getByRole("checkbox", { name: "Dwell to click" })).not.toBeChecked(); - expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled(); - }); - - it("explains telemetry consent and links to the privacy policy", async () => { - const first = render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - expect(screen.getByText(/Nothing is sent unless you choose Share diagnostics/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Don't share" })); - expect(await screen.findByText("Opted out. No diagnostic reports are stored or sent.")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Privacy policy" })).toHaveAttribute("href", "https://switchifyapp.com/privacy"); - - first.unmount(); - browserState.telemetry = { consent: "undecided", available: false }; - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - expect(await screen.findByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeDisabled(); - expect(screen.getByText("Diagnostic reporting is unavailable in this build.")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Share diagnostics" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Don't share" })).toBeEnabled(); - }); - - it("serializes rapid settings changes without applying a stale response", async () => { - const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = []; - vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => { - saves.push({ settings: structuredClone(settings), resolve }); - })); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); - fireEvent.click(screen.getByRole("button", { name: "75% pointer speed" })); - - expect(saves).toHaveLength(1); - expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true"); - - await act(async () => { - saves[0].resolve(stateWithSettings(saves[0].settings)); - }); - await waitFor(() => expect(saves).toHaveLength(2)); - expect(saves[1].settings.pointerScalePercent).toBe(75); - expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true"); - - await act(async () => { - saves[1].resolve(stateWithSettings(saves[1].settings)); - }); - }); - - it("preserves newer runtime state when a settings save completes", async () => { - let stateHandler: ((state: typeof browserState) => void) | undefined; - let finishSave: ((state: typeof browserState) => void) | undefined; - vi.spyOn(api, "onState").mockImplementation(async (handler) => { - stateHandler = handler; - return () => undefined; - }); - vi.spyOn(api, "saveSettings").mockImplementation(() => new Promise((resolve) => { - finishSave = resolve; - })); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); - const runtimeState = { - ...structuredClone(browserState), - bluetooth: "connected" as const, - connectedDeviceName: "Newer connected device", - }; - act(() => stateHandler?.(runtimeState)); - await act(async () => { - finishSave?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 50 })); - }); - - fireEvent.click(screen.getByRole("button", { name: "Home" })); - expect(screen.getByText("Newer connected device")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Disconnect" })).toBeInTheDocument(); - }); - - it("rebases local edits on newer backend settings", async () => { - let stateHandler: ((state: typeof browserState) => void) | undefined; - const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = []; - vi.spyOn(api, "onState").mockImplementation(async (handler) => { - stateHandler = handler; - return () => undefined; - }); - vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => { - saves.push({ settings: structuredClone(settings), resolve }); - })); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); - act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 }))); - - expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); - expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150"); - - await act(async () => { - saves[0].resolve(stateWithSettings(saves[0].settings)); - }); - await waitFor(() => expect(saves).toHaveLength(2)); - expect(saves[1].settings).toEqual(expect.objectContaining({ - pointerScalePercent: 150, - shareDiagnostics: true, - })); - - await act(async () => { - saves[1].resolve(stateWithSettings(saves[1].settings)); - }); - }); - - it("restores confirmed settings when automatic saving fails", async () => { - vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable")); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - - const startup = screen.getByRole("checkbox", { name: "Start with system" }); - fireEvent.click(startup); - expect(startup).toBeChecked(); - - expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable"); - expect(startup).not.toBeChecked(); - }); - it("creates a profile and records a desired key", async () => { let saved: SwitchProfile | undefined; vi.spyOn(api, "saveProfile").mockImplementation(async (profile) => { @@ -794,29 +492,6 @@ describe("Switchify PC shell", () => { await waitFor(() => expect(updates).toHaveFocus()); }); - it("focuses Updates when the update banner is selected from Settings", async () => { - browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - const updates = screen.getByRole("region", { name: "Updates" }); - expect(updates).not.toHaveFocus(); - fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" })); - await waitFor(() => expect(updates).toHaveFocus()); - }); - - it("does not replay a consumed Updates focus request on normal Settings navigation", async () => { - browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; - render(); - const banner = await screen.findByRole("status", { name: "Application update" }); - fireEvent.click(within(banner).getByRole("button", { name: "View update" })); - const updates = await screen.findByRole("region", { name: "Updates" }); - await waitFor(() => expect(updates).toHaveFocus()); - - fireEvent.click(screen.getByRole("button", { name: "Home" })); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByRole("region", { name: "Updates" })).not.toHaveFocus(); - }); - it("guides macOS users through required and stale accessibility entries", async () => { const originalPlatform = browserState.capabilities.platform; browserState.capabilities.platform = "macos"; diff --git a/src/App.tsx b/src/App.tsx index fcad1e39..87d1d359 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode, type Ref } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Accessibility, Bluetooth, CheckCircle2, ChevronRight, CircleHelp, Download, Copy, Home, Keyboard, Plus, Power, Radio, RefreshCw, Save, Settings, @@ -6,6 +6,9 @@ import { } from "lucide-react"; import { api, type ProfileExitAction } from "./api"; import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; +import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; +import { SettingsView } from "./settings/SettingsView"; +import { updateProgress, type UpdateAction } from "./settings/UpdatesSection"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -38,10 +41,6 @@ function StatusIcon({ ok, children }: { ok: boolean; children: ReactNode }) { return {children}; } -function Toggle({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (next: boolean) => void }) { - return ; -} - function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) { if (state.accessibility === "granted") return

Ready

; if (state.accessibility === "unavailable") return

Unavailable on this system

; @@ -284,84 +283,6 @@ function ProfilesView({ profiles, platform, saveProfile, deleteProfile, onDirtyC ; } -function SettingGroup({ title, description, children, id, sectionRef, focusable = false }: { title: string; description: string; children: ReactNode; id?: string; sectionRef?: Ref; focusable?: boolean }) { - const headingId = id ? `${id}-heading` : undefined; - return

{title}

{description}

{children}
; -} - -const pointerSpeedOptions = [5, 25, 50, 75, 100] as const; -const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5); -const repeatIntervalOptions = [100, 250, 500, 1000] as const; -const keyRepeatDelayOptions = [ - { value: 0, label: "None" }, - { value: 250, label: "Short" }, - { value: 500, label: "Medium" }, - { value: 1000, label: "Long" }, -] as const; -const accelerationOptions = [ - { value: 0, label: "Off" }, - { value: 500, label: "Short" }, - { value: 1000, label: "Medium" }, - { value: 2000, label: "Long" }, -] as const; -const dwellDelayOptions = [500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] as const; - -function movementValue(base: number, scale: number) { - const value = Math.min(50, Math.max(1, Math.round((base * scale / 100) * 2) / 2)); - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - -const changedSettingKeys = (previous: AppSettings, next: AppSettings) => - (Object.keys(next) as Array).filter((key) => previous[key] !== next[key]); - -function applyLocalSettings(base: AppSettings, local: AppSettings, keys: Set) { - const merged = { ...base }; - for (const key of keys) Object.assign(merged, { [key]: local[key] }); - return merged; -} - -type UpdateAction = "check" | "download" | "install"; - -function updateProgress(update: UpdateState) { - if (update.totalBytes && update.totalBytes > 0) { - return `${Math.min(100, Math.round(update.downloadedBytes * 100 / update.totalBytes))}%`; - } - return `${update.downloadedBytes.toLocaleString()} bytes`; -} - -function updateDescription(update: UpdateState) { - switch (update.status) { - case "unconfigured": return "Updates are unavailable in this build because its signed feed is not configured."; - case "idle": return "Automatic update checks are enabled."; - case "checking": return "Checking for updates…"; - case "available": return `Switchify PC ${update.version} is available.`; - case "downloading": return `Downloading Switchify PC ${update.version}…`; - case "readyToInstall": return `Switchify PC ${update.version} is ready to install.`; - case "applying": return `Installing Switchify PC ${update.version}…`; - case "current": return "Switchify PC is up to date."; - case "failed": return update.error ?? "The update operation failed."; - case "cancelled": return "Download cancelled. You can retry when ready."; - } -} - -function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (action: UpdateAction) => void; cancel: () => void }) { - const action = update.status === "available" || update.status === "cancelled" ? "download" - : update.status === "readyToInstall" ? "install" - : update.status === "failed" ? update.retryAction - : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null; - const label = update.status === "failed" ? "Retry" - : action === "download" ? (update.status === "cancelled" ? "Retry download" : "Download") - : action === "install" ? "Install and restart" : "Check for updates"; - return
-

{updateDescription(update)}

- {update.status === "downloading" && <> - - {updateProgress(update)} - } -
{action && }{update.status === "downloading" && }{(update.status === "checking" || update.status === "applying") && }
-
; -} - function UpdateBanner({ update, openUpdates }: { update: UpdateState; openUpdates: () => void }) { if (update.status !== "available" && update.status !== "downloading" && update.status !== "readyToInstall") return null; const message = update.status === "available" @@ -376,81 +297,6 @@ function UpdateBanner({ update, openUpdates }: { update: UpdateState; openUpdate ; } -function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void }) { - const updatesRef = useRef(null); - useEffect(() => { - if (!focusUpdates) return; - updatesRef.current?.scrollIntoView?.({ block: "start" }); - updatesRef.current?.focus({ preventScroll: true }); - onUpdatesFocused(); - }, [focusUpdates, onUpdatesFocused]); - const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value }); - return

Settings

Startup, pointer, privacy, and updates

- update("startWithSystem", value)} /> - -
Pointer speed {settings.pointerScalePercent}%
- {pointerSpeedOptions.map((value) => )} -
- {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)} -
-
- update("mouseRepeatEnabled", value)} /> -
-
Movement interval
- {repeatIntervalOptions.map((value) => )} -
-
Movement acceleration
- {accelerationOptions.map(({ value, label }) => )} -
-
Scroll interval
- {repeatIntervalOptions.map((value) => )} -
-
-
-
- update("keyRepeatEnabled", value)} /> -
-
Delay before repeating
- {keyRepeatDelayOptions.map(({ value, label }) => )} -
-
Key interval
- {repeatIntervalOptions.map((value) => )} -
-

Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.

-
-
-
- update("dwellClickEnabled", value)} /> -
-
Dwell delay
- {dwellDelayOptions.map((value) => )} -
-

After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it.

-
-
- {state.capabilities.cursorOverlay && <> - update("cursorOverlayEnabled", value)} /> -
-
Overlay visibility
- {(["onInput", "whileControlling"] as const).map((value) => )} -

On input hides shortly after pointer activity stops. While controlling stays visible until the session ends.

-
Overlay size
- {(["small", "medium", "large"] as const).map((value) => )} -
-
Overlay color
- {(["red", "green", "blue", "yellow", "white"] as const).map((value) => )} -
- update("cursorCrosshairs", value)} /> -
- } -
- update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

- -
; -} - function SupportView({ state, busy, perform, openSetup, openUpdates }: { state: AppState; busy: boolean; perform: (operation: () => Promise) => void; openSetup: () => void; openUpdates: () => void }) { const [tab, setTab] = useState<"setup" | "troubleshooting">("setup"); const bluetoothReady = state.bluetooth === "advertising" || state.bluetooth === "connected"; diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx new file mode 100644 index 00000000..2cd9f49f --- /dev/null +++ b/src/settings/SettingsView.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from "react"; +import { Settings } from "lucide-react"; +import type { AppSettings, AppState } from "../types"; +import { + SettingGroup, Toggle, accelerationOptions, dwellDelayOptions, keyRepeatDelayOptions, + movementValue, pointerSpeedOptions, pointerSpeedValues, repeatIntervalOptions, +} from "./controls"; +import { UpdateControls, type UpdateAction } from "./UpdatesSection"; + +export function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void }) { + const updatesRef = useRef(null); + useEffect(() => { + if (!focusUpdates) return; + updatesRef.current?.scrollIntoView?.({ block: "start" }); + updatesRef.current?.focus({ preventScroll: true }); + onUpdatesFocused(); + }, [focusUpdates, onUpdatesFocused]); + const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value }); + return

Settings

Startup, pointer, privacy, and updates

+ update("startWithSystem", value)} /> + +
Pointer speed {settings.pointerScalePercent}%
+ {pointerSpeedOptions.map((value) => )} +
+ {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)} +
+
+ update("mouseRepeatEnabled", value)} /> +
+
Movement interval
+ {repeatIntervalOptions.map((value) => )} +
+
Movement acceleration
+ {accelerationOptions.map(({ value, label }) => )} +
+
Scroll interval
+ {repeatIntervalOptions.map((value) => )} +
+
+
+
+ update("keyRepeatEnabled", value)} /> +
+
Delay before repeating
+ {keyRepeatDelayOptions.map(({ value, label }) => )} +
+
Key interval
+ {repeatIntervalOptions.map((value) => )} +
+

Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.

+
+
+
+ update("dwellClickEnabled", value)} /> +
+
Dwell delay
+ {dwellDelayOptions.map((value) => )} +
+

After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it.

+
+
+ {state.capabilities.cursorOverlay && <> + update("cursorOverlayEnabled", value)} /> +
+
Overlay visibility
+ {(["onInput", "whileControlling"] as const).map((value) => )} +

On input hides shortly after pointer activity stops. While controlling stays visible until the session ends.

+
Overlay size
+ {(["small", "medium", "large"] as const).map((value) => )} +
+
Overlay color
+ {(["red", "green", "blue", "yellow", "white"] as const).map((value) => )} +
+ update("cursorCrosshairs", value)} /> +
+ } +
+ update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

+ +
; +} diff --git a/src/settings/UpdatesSection.tsx b/src/settings/UpdatesSection.tsx new file mode 100644 index 00000000..eedd21e3 --- /dev/null +++ b/src/settings/UpdatesSection.tsx @@ -0,0 +1,44 @@ +import { Download, RefreshCw, X } from "lucide-react"; +import type { UpdateState } from "../types"; + +export type UpdateAction = "check" | "download" | "install"; + +export function updateProgress(update: UpdateState) { + if (update.totalBytes && update.totalBytes > 0) { + return `${Math.min(100, Math.round(update.downloadedBytes * 100 / update.totalBytes))}%`; + } + return `${update.downloadedBytes.toLocaleString()} bytes`; +} + +export function updateDescription(update: UpdateState) { + switch (update.status) { + case "unconfigured": return "Updates are unavailable in this build because its signed feed is not configured."; + case "idle": return "Automatic update checks are enabled."; + case "checking": return "Checking for updates…"; + case "available": return `Switchify PC ${update.version} is available.`; + case "downloading": return `Downloading Switchify PC ${update.version}…`; + case "readyToInstall": return `Switchify PC ${update.version} is ready to install.`; + case "applying": return `Installing Switchify PC ${update.version}…`; + case "current": return "Switchify PC is up to date."; + case "failed": return update.error ?? "The update operation failed."; + case "cancelled": return "Download cancelled. You can retry when ready."; + } +} + +export function UpdateControls({ update, run, cancel }: { update: UpdateState; run: (action: UpdateAction) => void; cancel: () => void }) { + const action = update.status === "available" || update.status === "cancelled" ? "download" + : update.status === "readyToInstall" ? "install" + : update.status === "failed" ? update.retryAction + : update.status === "idle" || update.status === "current" || update.status === "unconfigured" ? "check" : null; + const label = update.status === "failed" ? "Retry" + : action === "download" ? (update.status === "cancelled" ? "Retry download" : "Download") + : action === "install" ? "Install and restart" : "Check for updates"; + return
+

{updateDescription(update)}

+ {update.status === "downloading" && <> + + {updateProgress(update)} + } +
{action && }{update.status === "downloading" && }{(update.status === "checking" || update.status === "applying") && }
+
; +} diff --git a/src/settings/controls.tsx b/src/settings/controls.tsx new file mode 100644 index 00000000..905c0c35 --- /dev/null +++ b/src/settings/controls.tsx @@ -0,0 +1,32 @@ +import type { ReactNode, Ref } from "react"; + +export function Toggle({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (next: boolean) => void }) { + return ; +} + +export function SettingGroup({ title, description, children, id, sectionRef, focusable = false }: { title: string; description: string; children: ReactNode; id?: string; sectionRef?: Ref; focusable?: boolean }) { + const headingId = id ? `${id}-heading` : undefined; + return

{title}

{description}

{children}
; +} + +export const pointerSpeedOptions = [5, 25, 50, 75, 100] as const; +export const pointerSpeedValues = Array.from({ length: 45 }, (_, index) => (index + 1) * 5); +export const repeatIntervalOptions = [100, 250, 500, 1000] as const; +export const keyRepeatDelayOptions = [ + { value: 0, label: "None" }, + { value: 250, label: "Short" }, + { value: 500, label: "Medium" }, + { value: 1000, label: "Long" }, +] as const; +export const accelerationOptions = [ + { value: 0, label: "Off" }, + { value: 500, label: "Short" }, + { value: 1000, label: "Medium" }, + { value: 2000, label: "Long" }, +] as const; +export const dwellDelayOptions = [500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] as const; + +export function movementValue(base: number, scale: number) { + const value = Math.min(50, Math.max(1, Math.round((base * scale / 100) * 2) / 2)); + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} diff --git a/src/settings/diff.ts b/src/settings/diff.ts new file mode 100644 index 00000000..f0bb7a0c --- /dev/null +++ b/src/settings/diff.ts @@ -0,0 +1,10 @@ +import type { AppSettings } from "../types"; + +export const changedSettingKeys = (previous: AppSettings, next: AppSettings) => + (Object.keys(next) as Array).filter((key) => previous[key] !== next[key]); + +export function applyLocalSettings(base: AppSettings, local: AppSettings, keys: Set) { + const merged = { ...base }; + for (const key of keys) Object.assign(merged, { [key]: local[key] }); + return merged; +} diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx new file mode 100644 index 00000000..65ca49a4 --- /dev/null +++ b/src/settings/settings.test.tsx @@ -0,0 +1,352 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "../App"; +import { api, browserState } from "../api"; +import type { AppSettings } from "../types"; + +const defaultBrowserSettings = structuredClone(browserState.settings); + +function stateWithSettings(settings: AppSettings) { + return { ...structuredClone(browserState), settings: structuredClone(settings) }; +} + +describe("Switchify PC settings", () => { + beforeEach(() => { + browserState.settings = structuredClone(defaultBrowserSettings); + browserState.bluetooth = "initializing"; + browserState.pendingPairings = []; + browserState.pairedDevices = []; + browserState.connectedDeviceName = null; + browserState.lastActivity = null; + browserState.diagnostics = { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }; + browserState.telemetry = { consent: "undecided", available: true }; + browserState.updater = { status: "unconfigured", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + browserState.setup = { shown: true, completed: false, autoOpenEligible: false }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows update progress and exposes cancellation in Settings", async () => { + browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; + const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + + expect(screen.getByText("Downloading Switchify PC 1.0.0-beta.2…")).toBeInTheDocument(); + expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50"); + expect(document.querySelector(".update-controls > span")).toHaveTextContent("25%"); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("offers the correct retry action after a failure", async () => { + browserState.updater = { status: "failed", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: "Download failed", retryAction: "download" }; + const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("alert")).toHaveTextContent("Download failed"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(download).toHaveBeenCalledOnce(); + }); + + it("offers installation and restart when a download is ready", async () => { + browserState.updater = { status: "readyToInstall", version: "1.0.0-beta.2", downloadedBytes: 200, totalBytes: 200, error: null, retryAction: null }; + const install = vi.spyOn(api, "installUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); + expect(install).toHaveBeenCalledOnce(); + }); + + it("retries a cancelled download from the beginning", async () => { + browserState.updater = { status: "cancelled", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: "download" }; + const download = vi.spyOn(api, "downloadUpdate").mockResolvedValue(structuredClone(browserState)); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready."); + fireEvent.click(screen.getByRole("button", { name: "Retry download" })); + expect(download).toHaveBeenCalledOnce(); + }); + + it("reports manual update-check failures in Settings", async () => { + const checkForUpdates = vi.spyOn(api, "checkForUpdates").mockRejectedValue(new Error("Update service unavailable")); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Update service unavailable"); + checkForUpdates.mockRestore(); + }); + + it.each([ + ["checking", "Checking for updates…", "Checking"], + ["current", "Switchify PC is up to date.", "Check for updates"], + ] as const)("keeps the %s update state visible in Settings", async (status, description, action) => { + browserState.updater = { status, version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + expect(screen.getByText(description)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: action })).toBeInTheDocument(); + }); + + it("exposes key repeat settings and disables them with the toggle", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" }); + expect(toggle).toBeChecked(); + + const delay = screen.getByRole("group", { name: "Delay before repeating" }); + expect(delay).not.toBeDisabled(); + for (const label of ["None", "Short", "Medium", "Long"]) { + expect(within(delay).getByRole("button", { name: label })).toBeInTheDocument(); + } + expect(within(delay).getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true"); + + const interval = screen.getByRole("group", { name: "Key interval" }); + expect(within(interval).getByRole("button", { name: "0.25s" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(interval).getByRole("button", { name: "0.5s" })); + expect(within(interval).getByRole("button", { name: "0.5s" })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(within(delay).getByRole("button", { name: "None" })); + expect(within(delay).getByRole("button", { name: "None" })).toHaveAttribute("aria-pressed", "true"); + + // Turning the feature off must disable its cadence controls, matching the + // mouse repeat and dwell blocks. + fireEvent.click(toggle); + expect(screen.getByRole("group", { name: "Delay before repeating" })).toBeDisabled(); + expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled(); + }); + + it("opens settings with accessible native controls", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "100% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked(); + expect(screen.getByRole("group", { name: "Movement acceleration" })).not.toBeDisabled(); + expect(screen.getAllByRole("button", { name: "Medium" })[0]).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("2.5")).toBeInTheDocument(); + fireEvent.change(screen.getByRole("combobox", { name: "Exact pointer speed" }), { target: { value: "125" } }); + expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("125"); + expect(screen.getByText("5.5")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" })); + expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled(); + const dwell = screen.getByRole("checkbox", { name: "Dwell to click" }); + expect(dwell).not.toBeChecked(); + expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled(); + fireEvent.click(dwell); + const dwellDelay = screen.getByRole("group", { name: "Dwell delay" }); + expect(dwellDelay).not.toBeDisabled(); + expect(within(dwellDelay).getAllByRole("button")).toHaveLength(10); + for (const label of ["0.5s", "1s", "1.5s", "2s", "3s", "4s", "5s", "6s", "7s", "8s"]) { + expect(within(dwellDelay).getByRole("button", { name: label })).toBeInTheDocument(); + } + expect(within(dwellDelay).getByRole("button", { name: "1s" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(dwellDelay).getByRole("button", { name: "1.5s" })); + expect(within(dwellDelay).getByRole("button", { name: "1.5s" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/After Android pointer movement stops/)).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Show cursor overlay" })).toBeChecked(); + expect(screen.getByRole("button", { name: "While controlling" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/On input hides shortly/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "On input" })); + expect(screen.getByRole("button", { name: "On input" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getAllByRole("button", { name: "Medium" }).at(-1)).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("radio", { name: "Red" })).toBeChecked(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show crosshairs" })); + expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeChecked(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show cursor overlay" })); + expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeDisabled(); + }); + + it("automatically saves a settings change", async () => { + const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); + + await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ shareDiagnostics: true }))); + expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); + }); + + it("persists dwell enablement and delay through automatic settings saves", async () => { + const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" })); + await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 1000 }))); + const dwellDelay = screen.getByRole("group", { name: "Dwell delay" }); + fireEvent.click(within(dwellDelay).getByRole("button", { name: "8s" })); + await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 8000 }))); + expect(within(dwellDelay).getByRole("button", { name: "8s" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("restores confirmed dwell settings when automatic saving fails", async () => { + vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable")); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable"); + expect(screen.getByRole("checkbox", { name: "Dwell to click" })).not.toBeChecked(); + expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled(); + }); + + it("explains telemetry consent and links to the privacy policy", async () => { + const first = render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + expect(screen.getByText(/Nothing is sent unless you choose Share diagnostics/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Don't share" })); + expect(await screen.findByText("Opted out. No diagnostic reports are stored or sent.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Privacy policy" })).toHaveAttribute("href", "https://switchifyapp.com/privacy"); + + first.unmount(); + browserState.telemetry = { consent: "undecided", available: false }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + expect(await screen.findByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeDisabled(); + expect(screen.getByText("Diagnostic reporting is unavailable in this build.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Share diagnostics" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Don't share" })).toBeEnabled(); + }); + + it("serializes rapid settings changes without applying a stale response", async () => { + const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = []; + vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => { + saves.push({ settings: structuredClone(settings), resolve }); + })); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + fireEvent.click(screen.getByRole("button", { name: "75% pointer speed" })); + + expect(saves).toHaveLength(1); + expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + + await act(async () => { + saves[0].resolve(stateWithSettings(saves[0].settings)); + }); + await waitFor(() => expect(saves).toHaveLength(2)); + expect(saves[1].settings.pointerScalePercent).toBe(75); + expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + + await act(async () => { + saves[1].resolve(stateWithSettings(saves[1].settings)); + }); + }); + + it("preserves newer runtime state when a settings save completes", async () => { + let stateHandler: ((state: typeof browserState) => void) | undefined; + let finishSave: ((state: typeof browserState) => void) | undefined; + vi.spyOn(api, "onState").mockImplementation(async (handler) => { + stateHandler = handler; + return () => undefined; + }); + vi.spyOn(api, "saveSettings").mockImplementation(() => new Promise((resolve) => { + finishSave = resolve; + })); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + const runtimeState = { + ...structuredClone(browserState), + bluetooth: "connected" as const, + connectedDeviceName: "Newer connected device", + }; + act(() => stateHandler?.(runtimeState)); + await act(async () => { + finishSave?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 50 })); + }); + + fireEvent.click(screen.getByRole("button", { name: "Home" })); + expect(screen.getByText("Newer connected device")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Disconnect" })).toBeInTheDocument(); + }); + + it("rebases local edits on newer backend settings", async () => { + let stateHandler: ((state: typeof browserState) => void) | undefined; + const saves: Array<{ settings: AppSettings; resolve: (state: typeof browserState) => void }> = []; + vi.spyOn(api, "onState").mockImplementation(async (handler) => { + stateHandler = handler; + return () => undefined; + }); + vi.spyOn(api, "saveSettings").mockImplementation((settings) => new Promise((resolve) => { + saves.push({ settings: structuredClone(settings), resolve }); + })); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); + fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 }))); + + expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); + expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150"); + + await act(async () => { + saves[0].resolve(stateWithSettings(saves[0].settings)); + }); + await waitFor(() => expect(saves).toHaveLength(2)); + expect(saves[1].settings).toEqual(expect.objectContaining({ + pointerScalePercent: 150, + shareDiagnostics: true, + })); + + await act(async () => { + saves[1].resolve(stateWithSettings(saves[1].settings)); + }); + }); + + it("restores confirmed settings when automatic saving fails", async () => { + vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable")); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const startup = screen.getByRole("checkbox", { name: "Start with system" }); + fireEvent.click(startup); + expect(startup).toBeChecked(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Settings storage unavailable"); + expect(startup).not.toBeChecked(); + }); + + it("focuses Updates when the update banner is selected from Settings", async () => { + browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + const updates = screen.getByRole("region", { name: "Updates" }); + expect(updates).not.toHaveFocus(); + fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" })); + await waitFor(() => expect(updates).toHaveFocus()); + }); + + it("does not replay a consumed Updates focus request on normal Settings navigation", async () => { + browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + const banner = await screen.findByRole("status", { name: "Application update" }); + fireEvent.click(within(banner).getByRole("button", { name: "View update" })); + const updates = await screen.findByRole("region", { name: "Updates" }); + await waitFor(() => expect(updates).toHaveFocus()); + + fireEvent.click(screen.getByRole("button", { name: "Home" })); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("region", { name: "Updates" })).not.toHaveFocus(); + }); + +});