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>
This commit is contained in:
281
tests/stores/settings-store.test.ts
Normal file
281
tests/stores/settings-store.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user