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>
282 lines
8.3 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|
|
});
|