feat(bd-sec): implement Settings UI component with TDD
Settings includes: - Theme toggle (dark/light mode) - Notification preferences toggle - Sound effects toggle - Floating widget toggle - Hotkey configuration with validation - Reconciliation interval input - Default defer duration selector - Keyboard shortcuts display (read-only) - Lore database path configuration - Data directory info display 21 tests covering all settings functionality including: - Toggle behaviors - Hotkey validation - Input persistence on blur - Section organization
This commit is contained in:
380
src/components/Settings.tsx
Normal file
380
src/components/Settings.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* Settings -- User preferences and configuration panel.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Theme toggle (dark/light)
|
||||||
|
* - Notification preferences
|
||||||
|
* - Sound effects toggle
|
||||||
|
* - Floating widget toggle
|
||||||
|
* - Hotkey configuration
|
||||||
|
* - Reconciliation interval
|
||||||
|
* - Default defer duration
|
||||||
|
* - Keyboard shortcuts display
|
||||||
|
* - Data directory info
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { DeferDuration } from "@/lib/types";
|
||||||
|
|
||||||
|
/** Settings data structure matching ~/.local/share/mc/settings.json */
|
||||||
|
export interface SettingsData {
|
||||||
|
schemaVersion: number;
|
||||||
|
hotkeys: {
|
||||||
|
toggle: string;
|
||||||
|
capture: string;
|
||||||
|
};
|
||||||
|
lorePath: string | null;
|
||||||
|
reconciliationHours: number;
|
||||||
|
floatingWidget: boolean;
|
||||||
|
defaultDefer: DeferDuration;
|
||||||
|
sounds: boolean;
|
||||||
|
theme: "dark" | "light";
|
||||||
|
notifications: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsProps {
|
||||||
|
settings: SettingsData;
|
||||||
|
onSave: (settings: SettingsData) => void;
|
||||||
|
dataDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyboard shortcuts to display (not configurable, just informational) */
|
||||||
|
const KEYBOARD_SHORTCUTS = [
|
||||||
|
{ action: "Start task", shortcut: "S" },
|
||||||
|
{ action: "Skip task", shortcut: "K" },
|
||||||
|
{ action: "Defer 1 hour", shortcut: "D" },
|
||||||
|
{ action: "Defer tomorrow", shortcut: "T" },
|
||||||
|
{ action: "Command palette", shortcut: "Cmd+K" },
|
||||||
|
{ action: "Quick capture", shortcut: "Cmd+Shift+C" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Validate hotkey format (e.g., Meta+Shift+M) */
|
||||||
|
function isValidHotkey(value: string): boolean {
|
||||||
|
// Accept common modifier patterns
|
||||||
|
const pattern = /^(Meta|Ctrl|Alt|Shift)(\+(Meta|Ctrl|Alt|Shift))*\+[A-Z]$/;
|
||||||
|
return pattern.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle switch component */
|
||||||
|
function Toggle({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const id = label.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex cursor-pointer items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-zinc-300">{label}</span>
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
checked ? "bg-blue-600" : "bg-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||||
|
checked ? "translate-x-5" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section wrapper component */
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-zinc-800 pb-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-zinc-100">{title}</h2>
|
||||||
|
<div className="space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Settings({
|
||||||
|
settings,
|
||||||
|
onSave,
|
||||||
|
dataDir,
|
||||||
|
}: SettingsProps): React.ReactElement {
|
||||||
|
// Local state for form validation
|
||||||
|
const [hotkeyErrors, setHotkeyErrors] = useState<{
|
||||||
|
toggle?: string;
|
||||||
|
capture?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// Local state for controlled hotkey inputs
|
||||||
|
const [hotkeyValues, setHotkeyValues] = useState({
|
||||||
|
toggle: settings.hotkeys.toggle,
|
||||||
|
capture: settings.hotkeys.capture,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local state for reconciliation interval
|
||||||
|
const [reconciliationValue, setReconciliationValue] = useState(
|
||||||
|
String(settings.reconciliationHours)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle toggle changes
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(field: keyof SettingsData, value: boolean) => {
|
||||||
|
onSave({ ...settings, [field]: value });
|
||||||
|
},
|
||||||
|
[settings, onSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle theme toggle
|
||||||
|
const handleThemeToggle = useCallback(
|
||||||
|
(isDark: boolean) => {
|
||||||
|
onSave({ ...settings, theme: isDark ? "dark" : "light" });
|
||||||
|
},
|
||||||
|
[settings, onSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle hotkey input change with live validation
|
||||||
|
const handleHotkeyInput = useCallback(
|
||||||
|
(field: "toggle" | "capture", value: string) => {
|
||||||
|
setHotkeyValues((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
if (value && !isValidHotkey(value)) {
|
||||||
|
setHotkeyErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: "Invalid hotkey format",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setHotkeyErrors((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
delete updated[field];
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle hotkey blur to save valid value
|
||||||
|
const handleHotkeyBlur = useCallback(
|
||||||
|
(field: "toggle" | "capture") => {
|
||||||
|
const value = hotkeyValues[field];
|
||||||
|
if (value && isValidHotkey(value)) {
|
||||||
|
onSave({
|
||||||
|
...settings,
|
||||||
|
hotkeys: { ...settings.hotkeys, [field]: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings, onSave, hotkeyValues]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle reconciliation input change
|
||||||
|
const handleReconciliationChange = useCallback((value: string) => {
|
||||||
|
setReconciliationValue(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle reconciliation blur (save valid value)
|
||||||
|
const handleReconciliationBlur = useCallback(() => {
|
||||||
|
const num = parseInt(reconciliationValue, 10);
|
||||||
|
if (!Number.isNaN(num) && num > 0) {
|
||||||
|
onSave({ ...settings, reconciliationHours: num });
|
||||||
|
}
|
||||||
|
}, [settings, onSave, reconciliationValue]);
|
||||||
|
|
||||||
|
// Handle select change
|
||||||
|
const handleSelectChange = useCallback(
|
||||||
|
(field: keyof SettingsData, value: string) => {
|
||||||
|
onSave({ ...settings, [field]: value });
|
||||||
|
},
|
||||||
|
[settings, onSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-100">Settings</h1>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<Section title="Appearance">
|
||||||
|
<Toggle
|
||||||
|
label="Dark mode"
|
||||||
|
checked={settings.theme === "dark"}
|
||||||
|
onChange={handleThemeToggle}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Behavior */}
|
||||||
|
<Section title="Behavior">
|
||||||
|
<Toggle
|
||||||
|
label="Notifications"
|
||||||
|
checked={settings.notifications}
|
||||||
|
onChange={(v) => handleToggle("notifications", v)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Sound effects"
|
||||||
|
checked={settings.sounds}
|
||||||
|
onChange={(v) => handleToggle("sounds", v)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Floating widget"
|
||||||
|
checked={settings.floatingWidget}
|
||||||
|
onChange={(v) => handleToggle("floatingWidget", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Reconciliation interval */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<label
|
||||||
|
htmlFor="reconciliation"
|
||||||
|
className="text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
Reconciliation interval (hours)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reconciliation"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="24"
|
||||||
|
value={reconciliationValue}
|
||||||
|
onChange={(e) => handleReconciliationChange(e.target.value)}
|
||||||
|
onBlur={handleReconciliationBlur}
|
||||||
|
className="w-20 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default defer duration */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<label
|
||||||
|
htmlFor="default-defer"
|
||||||
|
className="text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
Default defer duration
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="default-defer"
|
||||||
|
value={settings.defaultDefer}
|
||||||
|
onChange={(e) => handleSelectChange("defaultDefer", e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="1h">1 hour</option>
|
||||||
|
<option value="3h">3 hours</option>
|
||||||
|
<option value="tomorrow">Tomorrow</option>
|
||||||
|
<option value="next_week">Next week</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Hotkeys */}
|
||||||
|
<Section title="Hotkeys">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="toggle-hotkey"
|
||||||
|
className="mb-1 block text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
Toggle hotkey
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="toggle-hotkey"
|
||||||
|
type="text"
|
||||||
|
value={hotkeyValues.toggle}
|
||||||
|
onChange={(e) => handleHotkeyInput("toggle", e.target.value)}
|
||||||
|
onBlur={() => handleHotkeyBlur("toggle")}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
{hotkeyErrors.toggle && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">{hotkeyErrors.toggle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="capture-hotkey"
|
||||||
|
className="mb-1 block text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
Capture hotkey
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="capture-hotkey"
|
||||||
|
type="text"
|
||||||
|
value={hotkeyValues.capture}
|
||||||
|
onChange={(e) => handleHotkeyInput("capture", e.target.value)}
|
||||||
|
onBlur={() => handleHotkeyBlur("capture")}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
{hotkeyErrors.capture && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">{hotkeyErrors.capture}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts (read-only) */}
|
||||||
|
<Section title="Keyboard Shortcuts">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/50">
|
||||||
|
{KEYBOARD_SHORTCUTS.map(({ action, shortcut }) => (
|
||||||
|
<div
|
||||||
|
key={action}
|
||||||
|
className="flex items-center justify-between border-b border-zinc-800 px-3 py-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-zinc-400">{action}</span>
|
||||||
|
<kbd className="rounded bg-zinc-700 px-2 py-0.5 font-mono text-xs text-zinc-300">
|
||||||
|
{shortcut}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<Section title="Data">
|
||||||
|
{/* Lore path */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="lore-path"
|
||||||
|
className="mb-1 block text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
Lore database path
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lore-path"
|
||||||
|
type="text"
|
||||||
|
value={settings.lorePath ?? ""}
|
||||||
|
placeholder="~/.local/share/lore/lore.db"
|
||||||
|
onChange={(e) =>
|
||||||
|
onSave({
|
||||||
|
...settings,
|
||||||
|
lorePath: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Leave empty to use the default location
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data directory info */}
|
||||||
|
{dataDir && (
|
||||||
|
<div className="mt-4 rounded-md border border-zinc-800 bg-zinc-900/50 p-3">
|
||||||
|
<p className="text-xs text-zinc-500">Data directory</p>
|
||||||
|
<p className="font-mono text-sm text-zinc-400">{dataDir}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
tests/components/Settings.test.tsx
Normal file
284
tests/components/Settings.test.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Settings component.
|
||||||
|
*
|
||||||
|
* TDD: These tests define the expected behavior before implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Settings } from "@/components/Settings";
|
||||||
|
import type { SettingsData } from "@/components/Settings";
|
||||||
|
|
||||||
|
const defaultSettings: SettingsData = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
hotkeys: {
|
||||||
|
toggle: "Meta+Shift+M",
|
||||||
|
capture: "Meta+Shift+C",
|
||||||
|
},
|
||||||
|
lorePath: null,
|
||||||
|
reconciliationHours: 6,
|
||||||
|
floatingWidget: false,
|
||||||
|
defaultDefer: "1h",
|
||||||
|
sounds: true,
|
||||||
|
theme: "dark",
|
||||||
|
notifications: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Settings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loading state", () => {
|
||||||
|
it("displays current settings on mount", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
// Check hotkey displays
|
||||||
|
expect(screen.getByDisplayValue("Meta+Shift+M")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("Meta+Shift+C")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows data directory info", () => {
|
||||||
|
render(
|
||||||
|
<Settings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
dataDir="~/.local/share/mc"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/\.local\/share\/mc/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("theme toggle", () => {
|
||||||
|
it("renders theme toggle with current value", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
const themeToggle = screen.getByRole("switch", { name: /dark mode/i });
|
||||||
|
expect(themeToggle).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when theme is toggled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const themeToggle = screen.getByRole("switch", { name: /dark mode/i });
|
||||||
|
await user.click(themeToggle);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
theme: "light",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notification preferences", () => {
|
||||||
|
it("renders notification toggle", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
const notifToggle = screen.getByRole("switch", { name: /notifications/i });
|
||||||
|
expect(notifToggle).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when notifications toggled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const notifToggle = screen.getByRole("switch", { name: /notifications/i });
|
||||||
|
await user.click(notifToggle);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
notifications: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sound effects", () => {
|
||||||
|
it("renders sound effects toggle", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
const soundToggle = screen.getByRole("switch", { name: /sound effects/i });
|
||||||
|
expect(soundToggle).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when sound effects toggled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const soundToggle = screen.getByRole("switch", { name: /sound effects/i });
|
||||||
|
await user.click(soundToggle);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sounds: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("floating widget", () => {
|
||||||
|
it("renders floating widget toggle", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
const widgetToggle = screen.getByRole("switch", {
|
||||||
|
name: /floating widget/i,
|
||||||
|
});
|
||||||
|
expect(widgetToggle).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when floating widget toggled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const widgetToggle = screen.getByRole("switch", {
|
||||||
|
name: /floating widget/i,
|
||||||
|
});
|
||||||
|
await user.click(widgetToggle);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
floatingWidget: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hotkey settings", () => {
|
||||||
|
it("validates hotkey format", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
const toggleInput = screen.getByLabelText(/toggle hotkey/i);
|
||||||
|
await user.clear(toggleInput);
|
||||||
|
await user.type(toggleInput, "invalid");
|
||||||
|
|
||||||
|
expect(screen.getByText(/Invalid hotkey format/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid hotkey format", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const toggleInput = screen.getByLabelText(/toggle hotkey/i);
|
||||||
|
await user.clear(toggleInput);
|
||||||
|
await user.type(toggleInput, "Meta+Shift+K");
|
||||||
|
await user.tab(); // Blur to trigger save
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
hotkeys: expect.objectContaining({
|
||||||
|
toggle: "Meta+Shift+K",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconciliation interval", () => {
|
||||||
|
it("displays current interval", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates interval on change", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const intervalInput = screen.getByLabelText(/reconciliation/i);
|
||||||
|
await user.clear(intervalInput);
|
||||||
|
await user.type(intervalInput, "12");
|
||||||
|
await user.tab();
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reconciliationHours: 12,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("default defer duration", () => {
|
||||||
|
it("displays current default defer", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
const deferSelect = screen.getByLabelText(/default defer/i);
|
||||||
|
expect(deferSelect).toHaveValue("1h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates default defer on change", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<Settings settings={defaultSettings} onSave={onSave} />);
|
||||||
|
|
||||||
|
const deferSelect = screen.getByLabelText(/default defer/i);
|
||||||
|
await user.selectOptions(deferSelect, "3h");
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
defaultDefer: "3h",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyboard shortcuts display", () => {
|
||||||
|
it("shows keyboard shortcuts section", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/keyboard shortcuts/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays common shortcuts", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
// Check for common shortcut displays
|
||||||
|
expect(screen.getByText(/start task/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/skip task/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/command palette/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lore path", () => {
|
||||||
|
it("shows default lore path when null", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(/\.local\/share\/lore/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows custom lore path when set", () => {
|
||||||
|
const settingsWithPath = {
|
||||||
|
...defaultSettings,
|
||||||
|
lorePath: "/custom/path/lore.db",
|
||||||
|
};
|
||||||
|
render(<Settings settings={settingsWithPath} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("/custom/path/lore.db")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("section organization", () => {
|
||||||
|
it("groups settings into sections", () => {
|
||||||
|
render(<Settings settings={defaultSettings} onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
// Check for section headers (h2 elements)
|
||||||
|
expect(screen.getByRole("heading", { name: /appearance/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: /behavior/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: /hotkeys/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: /^data$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user