Files
mission-control/tests/hooks/useActions.test.ts
teernisse 23a4e6bf19 fix: improve error handling across Rust and TypeScript
- Log swallowed errors in file watcher and window operations (lib.rs, watcher.rs)
- Propagate recovery errors from bridge::recover_pending to SyncResult.errors
  so the frontend can display them instead of silently dropping failures
- Fix useTauriEvent/useTauriEvents race condition where cleanup fires before
  async listen() resolves, leaking the listener (cancelled flag pattern)
- Guard computeStaleness against invalid date strings (NaN -> 'normal'
  instead of incorrectly returning 'urgent')
- Strengthen isMcError type guard to check field types, not just presence
- Log warning when data directory resolution falls back to '.' (state.rs, bridge.rs)
- Add test for computeStaleness with invalid date inputs
2026-02-26 10:14:35 -05:00

330 lines
8.1 KiB
TypeScript

/**
* Tests for useActions hook.
*
* TDD: These tests define the expected behavior before implementation.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useActions } from "@/hooks/useActions";
// Mock Tauri shell plugin
const mockOpen = vi.fn();
vi.mock("@tauri-apps/plugin-shell", () => ({
open: (...args: unknown[]) => mockOpen(...args),
}));
// Mock Tauri invoke
const mockInvoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
// Mock the focus store
const mockLogDecision = vi.fn();
const mockUpdateItem = vi.fn();
const mockAct = vi.fn();
vi.mock("@/stores/focus-store", () => ({
useFocusStore: () => ({
logDecision: mockLogDecision,
updateItem: mockUpdateItem,
act: mockAct,
}),
}));
describe("useActions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInvoke.mockResolvedValue(undefined);
mockOpen.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe("start", () => {
it("opens URL in browser", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.start({
id: "br-x7f",
url: "https://gitlab.com/platform/core/-/merge_requests/847",
title: "Test MR",
});
});
expect(mockOpen).toHaveBeenCalledWith(
"https://gitlab.com/platform/core/-/merge_requests/847"
);
});
it("does not open URL if none provided", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.start({
id: "br-x7f",
title: "Manual task",
});
});
expect(mockOpen).not.toHaveBeenCalled();
});
it("logs decision via Tauri", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.start({
id: "br-x7f",
title: "Test",
});
});
expect(mockInvoke).toHaveBeenCalledWith(
"log_decision",
expect.objectContaining({
entry: expect.objectContaining({
action: "start",
bead_id: "br-x7f",
}),
})
);
});
});
describe("defer", () => {
it("calculates correct snooze time for 1h", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T10:00:00Z"));
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.defer(
{ id: "br-x7f", title: "Test" },
"1h",
"Need more time"
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"update_item",
expect.objectContaining({
id: "br-x7f",
updates: expect.objectContaining({
snoozed_until: "2026-02-25T11:00:00.000Z",
}),
})
);
});
it("calculates correct snooze time for 3h", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T10:00:00Z"));
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.defer(
{ id: "br-x7f", title: "Test" },
"3h",
null
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"update_item",
expect.objectContaining({
id: "br-x7f",
updates: expect.objectContaining({
snoozed_until: "2026-02-25T13:00:00.000Z",
}),
})
);
});
it("defer tomorrow uses 9am next day", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T22:00:00Z")); // 10pm
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.defer(
{ id: "br-x7f", title: "Test" },
"tomorrow",
null
);
});
// Should be 9am on Feb 26
expect(mockInvoke).toHaveBeenCalledWith(
"update_item",
expect.objectContaining({
id: "br-x7f",
updates: expect.objectContaining({
snoozed_until: expect.stringContaining("2026-02-26T09:00:00"),
}),
})
);
});
it("logs decision with reason", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T10:00:00Z"));
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.defer(
{ id: "br-x7f", title: "Test" },
"1h",
"In a meeting"
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"log_decision",
expect.objectContaining({
entry: expect.objectContaining({
action: "defer",
bead_id: "br-x7f",
reason: "In a meeting",
}),
})
);
});
it("advances to next item in queue", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T10:00:00Z"));
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.defer(
{ id: "br-x7f", title: "Test" },
"1h",
null
);
});
expect(mockAct).toHaveBeenCalledWith("defer_1h", undefined);
});
});
describe("skip", () => {
it("marks item as skipped for today", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.skip(
{ id: "br-x7f", title: "Test" },
"Not urgent"
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"update_item",
expect.objectContaining({
id: "br-x7f",
updates: expect.objectContaining({
skipped_today: true,
}),
})
);
});
it("logs decision with reason", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.skip(
{ id: "br-x7f", title: "Test" },
"Low priority"
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"log_decision",
expect.objectContaining({
entry: expect.objectContaining({
action: "skip",
bead_id: "br-x7f",
reason: "Low priority",
}),
})
);
});
it("advances to next item in queue", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.skip({ id: "br-x7f", title: "Test" }, null);
});
expect(mockAct).toHaveBeenCalledWith("skip", undefined);
});
});
describe("complete", () => {
it("closes bead via Tauri", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.complete(
{ id: "br-x7f", title: "Test" },
"Fixed the bug"
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"close_bead",
expect.objectContaining({
bead_id: "br-x7f",
reason: "Fixed the bug",
})
);
});
it("logs decision", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.complete(
{ id: "br-x7f", title: "Test" },
"Done"
);
});
expect(mockInvoke).toHaveBeenCalledWith(
"log_decision",
expect.objectContaining({
entry: expect.objectContaining({
action: "complete",
bead_id: "br-x7f",
reason: "Done",
}),
})
);
});
it("advances to next item in queue", async () => {
const { result } = renderHook(() => useActions());
await act(async () => {
await result.current.complete(
{ id: "br-x7f", title: "Test" },
null
);
});
expect(mockAct).toHaveBeenCalled();
});
});
});