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:
teernisse
2026-02-26 11:00:32 -05:00
parent d1e9c6e65d
commit ac34602b7b
2 changed files with 664 additions and 0 deletions

View 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();
});
});
});