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
285 lines
8.6 KiB
TypeScript
285 lines
8.6 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|