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:
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