Files
mission-control/tests/stores/settings-store.test.ts
teernisse f5ce8a9091 feat(followup): implement PLAN-FOLLOWUP.md gap fixes
Complete implementation of 7 slices addressing E2E testing gaps:

Slice 0+1: Wire Actions + ReasonPrompt
- FocusView now uses useActions hook instead of direct act() calls
- Added pendingAction state pattern for skip/defer/complete actions
- ReasonPrompt integration with proper confirm/cancel flow
- Tags support in DecisionEntry interface

Slice 2: Drag Reorder UI
- Installed @dnd-kit (core, sortable, utilities)
- QueueView with DndContext, SortableContext, verticalListSortingStrategy
- SortableQueueItem wrapper component using useSortable hook
- pendingReorder state with ReasonPrompt for reorder reasons
- Cmd+Up/Down keyboard shortcuts for accessibility
- Fixed: Store item ID in PendingReorder to avoid stale queue reference

Slice 3: System Tray Integration
- tray.rs with TrayState, setup_tray, toggle_window_visibility
- Menu with Show/Quit items
- Left-click toggles window visibility
- update_tray_badge command updates tooltip with item count
- Frontend wiring in AppShell

Slice 4: E2E Test Updates
- Fixed test selectors for InboxView, Queue badge
- Exposed inbox store for test seeding

Slice 5: Staleness Visualization
- Already implemented in computeStaleness() with tests

Slice 6: Quick Wiring
- onStartBatch callback wired to QueueView
- SyncStatus rendered in nav area
- SettingsView renders Settings component

Slice 7: State Persistence
- settings-store with hydrate/update methods
- Tauri backend integration via read_settings/write_settings
- AppShell hydrates settings on mount

Bug fixes from code review:
- close_bead now has error isolation (try/catch) so decision logging
  and queue advancement continue even if bead close fails
- PendingReorder stores item ID to avoid stale queue reference

E2E tests for all ACs (tests/e2e/followup-acs.spec.ts):
- AC-F1: Drag reorder (4 tests)
- AC-F2: ReasonPrompt integration (7 tests)
- AC-F5: Staleness visualization (3 tests)
- AC-F6: Batch mode (2 tests)
- AC-F7: SyncStatus (2 tests)
- ReasonPrompt behavior (3 tests)

Tests: 388 frontend + 119 Rust + 32 E2E all passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 17:28:28 -05:00

282 lines
8.3 KiB
TypeScript

/**
* Tests for settings-store.
*
* Verifies:
* 1. Default values are set correctly
* 2. hydrate() loads settings from Tauri backend
* 3. hydrate() handles missing/null state gracefully
* 4. hydrate() handles backend errors gracefully
* 5. hydrate() validates types before applying
* 6. update() persists changes to backend and updates store
* 7. update() merges with existing state file data
* 8. update() handles write errors gracefully (no partial state)
* 9. extractSettings excludes methods from persisted data
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act } from "@testing-library/react";
// Mock Tauri bindings (readState/writeState are re-exports from bindings)
const mockReadState = vi.fn();
const mockWriteState = vi.fn();
vi.mock("@/lib/tauri", () => ({
readState: (...args: unknown[]) => mockReadState(...args),
writeState: (...args: unknown[]) => mockWriteState(...args),
}));
// Import after mocking
import { useSettingsStore } from "@/stores/settings-store";
describe("useSettingsStore", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset store to defaults
useSettingsStore.setState({
syncInterval: 15,
notificationsEnabled: true,
quickCaptureShortcut: "CommandOrControl+Shift+C",
});
});
describe("defaults", () => {
it("has correct default values", () => {
const state = useSettingsStore.getState();
expect(state.syncInterval).toBe(15);
expect(state.notificationsEnabled).toBe(true);
expect(state.quickCaptureShortcut).toBe("CommandOrControl+Shift+C");
});
});
describe("hydrate", () => {
it("loads settings from Tauri backend", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: {
settings: {
syncInterval: 30,
notificationsEnabled: false,
quickCaptureShortcut: "Meta+Shift+X",
},
},
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
const state = useSettingsStore.getState();
expect(state.syncInterval).toBe(30);
expect(state.notificationsEnabled).toBe(false);
expect(state.quickCaptureShortcut).toBe("Meta+Shift+X");
});
it("keeps defaults when state is null (first run)", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: null,
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
const state = useSettingsStore.getState();
expect(state.syncInterval).toBe(15);
expect(state.notificationsEnabled).toBe(true);
});
it("keeps defaults when state has no settings key", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: { otherStoreData: "value" },
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
const state = useSettingsStore.getState();
expect(state.syncInterval).toBe(15);
});
it("handles backend read errors gracefully", async () => {
mockReadState.mockResolvedValue({
status: "error",
error: { code: "IO_ERROR", message: "File not found", recoverable: true },
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
// Should keep defaults, not crash
const state = useSettingsStore.getState();
expect(state.syncInterval).toBe(15);
});
it("validates syncInterval before applying", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: {
settings: {
syncInterval: 42, // Invalid - not 5, 15, or 30
notificationsEnabled: false,
},
},
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
const state = useSettingsStore.getState();
// Invalid syncInterval should be ignored, keep default
expect(state.syncInterval).toBe(15);
// Valid field should still be applied
expect(state.notificationsEnabled).toBe(false);
});
it("ignores non-boolean notificationsEnabled", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: {
settings: {
notificationsEnabled: "yes", // Wrong type
},
},
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
expect(useSettingsStore.getState().notificationsEnabled).toBe(true);
});
it("ignores non-string quickCaptureShortcut", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: {
settings: {
quickCaptureShortcut: 123, // Wrong type
},
},
});
await act(async () => {
await useSettingsStore.getState().hydrate();
});
expect(useSettingsStore.getState().quickCaptureShortcut).toBe(
"CommandOrControl+Shift+C"
);
});
});
describe("update", () => {
it("persists changes to backend and updates store", async () => {
// Existing state in backend
mockReadState.mockResolvedValue({
status: "ok",
data: { otherData: "preserved" },
});
mockWriteState.mockResolvedValue({ status: "ok", data: null });
await act(async () => {
await useSettingsStore.getState().update({ syncInterval: 5 });
});
// Store should be updated
expect(useSettingsStore.getState().syncInterval).toBe(5);
// Backend should receive merged state
expect(mockWriteState).toHaveBeenCalledWith({
otherData: "preserved",
settings: {
syncInterval: 5,
notificationsEnabled: true,
quickCaptureShortcut: "CommandOrControl+Shift+C",
},
});
});
it("merges with existing backend state", async () => {
mockReadState.mockResolvedValue({
status: "ok",
data: {
"mc-focus-store": { current: null, queue: [] },
settings: { syncInterval: 30 },
},
});
mockWriteState.mockResolvedValue({ status: "ok", data: null });
await act(async () => {
await useSettingsStore.getState().update({ notificationsEnabled: false });
});
// Should preserve other keys in state file
expect(mockWriteState).toHaveBeenCalledWith(
expect.objectContaining({
"mc-focus-store": { current: null, queue: [] },
})
);
});
it("does not update store on write failure", async () => {
mockReadState.mockResolvedValue({ status: "ok", data: {} });
mockWriteState.mockResolvedValue({
status: "error",
error: { code: "IO_ERROR", message: "Disk full", recoverable: false },
});
await act(async () => {
await useSettingsStore.getState().update({ syncInterval: 30 });
});
// Store should NOT be updated since write failed
expect(useSettingsStore.getState().syncInterval).toBe(15);
});
it("handles null existing state on update", async () => {
mockReadState.mockResolvedValue({ status: "ok", data: null });
mockWriteState.mockResolvedValue({ status: "ok", data: null });
await act(async () => {
await useSettingsStore.getState().update({
quickCaptureShortcut: "Meta+K",
});
});
expect(useSettingsStore.getState().quickCaptureShortcut).toBe("Meta+K");
expect(mockWriteState).toHaveBeenCalledWith({
settings: {
syncInterval: 15,
notificationsEnabled: true,
quickCaptureShortcut: "Meta+K",
},
});
});
it("persisted data does not include methods", async () => {
mockReadState.mockResolvedValue({ status: "ok", data: {} });
mockWriteState.mockResolvedValue({ status: "ok", data: null });
await act(async () => {
await useSettingsStore.getState().update({ syncInterval: 5 });
});
const writtenState = mockWriteState.mock.calls[0][0] as Record<string, unknown>;
const writtenSettings = writtenState.settings as Record<string, unknown>;
expect(writtenSettings).not.toHaveProperty("hydrate");
expect(writtenSettings).not.toHaveProperty("update");
expect(Object.keys(writtenSettings)).toEqual([
"syncInterval",
"notificationsEnabled",
"quickCaptureShortcut",
]);
});
});
});