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
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -157,9 +157,14 @@ fn sync_now_inner(
|
|||||||
let mut map = bridge.load_map()?;
|
let mut map = bridge.load_map()?;
|
||||||
|
|
||||||
// Recover any pending entries from a previous crash
|
// Recover any pending entries from a previous crash
|
||||||
bridge.recover_pending(&mut map)?;
|
let (_recovered, recovery_errors) = bridge.recover_pending(&mut map)?;
|
||||||
|
|
||||||
let result = bridge.incremental_sync(&mut map)?;
|
let mut result = bridge.incremental_sync(&mut map)?;
|
||||||
|
|
||||||
|
// Surface recovery errors alongside sync errors
|
||||||
|
if !recovery_errors.is_empty() {
|
||||||
|
result.errors.extend(recovery_errors);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -182,9 +187,14 @@ fn reconcile_inner(
|
|||||||
let mut map = bridge.load_map()?;
|
let mut map = bridge.load_map()?;
|
||||||
|
|
||||||
// Recover pending first
|
// Recover pending first
|
||||||
bridge.recover_pending(&mut map)?;
|
let (_recovered, recovery_errors) = bridge.recover_pending(&mut map)?;
|
||||||
|
|
||||||
let result = bridge.full_reconciliation(&mut map)?;
|
let mut result = bridge.full_reconciliation(&mut map)?;
|
||||||
|
|
||||||
|
// Surface recovery errors alongside reconciliation errors
|
||||||
|
if !recovery_errors.is_empty() {
|
||||||
|
result.errors.extend(recovery_errors);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,12 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
|||||||
/// Create a new bridge with the given CLI implementations
|
/// Create a new bridge with the given CLI implementations
|
||||||
pub fn new(lore: L, beads: B) -> Self {
|
pub fn new(lore: L, beads: B) -> Self {
|
||||||
let data_dir = dirs::data_local_dir()
|
let data_dir = dirs::data_local_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not determine local data directory ($HOME may be unset), falling back to '.'"
|
||||||
|
);
|
||||||
|
PathBuf::from(".")
|
||||||
|
})
|
||||||
.join("mc");
|
.join("mc");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -331,7 +336,9 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
|||||||
/// On startup, scan for entries with pending=true:
|
/// On startup, scan for entries with pending=true:
|
||||||
/// - If bead_id is None -> retry creation
|
/// - If bead_id is None -> retry creation
|
||||||
/// - If bead_id exists -> clear pending flag
|
/// - If bead_id exists -> clear pending flag
|
||||||
pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result<usize, BridgeError> {
|
///
|
||||||
|
/// Returns (recovered_count, error_messages) so callers can surface failures.
|
||||||
|
pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result<(usize, Vec<String>), BridgeError> {
|
||||||
let pending_keys: Vec<String> = map
|
let pending_keys: Vec<String> = map
|
||||||
.mappings
|
.mappings
|
||||||
.iter()
|
.iter()
|
||||||
@@ -340,6 +347,7 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut recovered = 0;
|
let mut recovered = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
for key in &pending_keys {
|
for key in &pending_keys {
|
||||||
if let Some(entry) = map.mappings.get_mut(key) {
|
if let Some(entry) = map.mappings.get_mut(key) {
|
||||||
@@ -356,6 +364,7 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to recover bead for {}: {}", key, e);
|
tracing::error!("Failed to recover bead for {}: {}", key, e);
|
||||||
|
errors.push(format!("Failed to recover pending bead for {}: {}", key, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -369,7 +378,7 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
|||||||
self.save_map(map)?;
|
self.save_map(map)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(recovered)
|
Ok((recovered, errors))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Incremental sync: process `since_last_check` events from lore.
|
/// Incremental sync: process `since_last_check` events from lore.
|
||||||
@@ -904,9 +913,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
bridge.save_map(&map).unwrap();
|
bridge.save_map(&map).unwrap();
|
||||||
let recovered = bridge.recover_pending(&mut map).unwrap();
|
let (recovered, errors) = bridge.recover_pending(&mut map).unwrap();
|
||||||
|
|
||||||
assert_eq!(recovered, 1);
|
assert_eq!(recovered, 1);
|
||||||
|
assert!(errors.is_empty());
|
||||||
let entry = &map.mappings["issue:g/p:42"];
|
let entry = &map.mappings["issue:g/p:42"];
|
||||||
assert_eq!(entry.bead_id, Some("bd-recovered".to_string()));
|
assert_eq!(entry.bead_id, Some("bd-recovered".to_string()));
|
||||||
assert!(!entry.pending);
|
assert!(!entry.pending);
|
||||||
@@ -931,9 +941,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
bridge.save_map(&map).unwrap();
|
bridge.save_map(&map).unwrap();
|
||||||
let recovered = bridge.recover_pending(&mut map).unwrap();
|
let (recovered, errors) = bridge.recover_pending(&mut map).unwrap();
|
||||||
|
|
||||||
assert_eq!(recovered, 1);
|
assert_eq!(recovered, 1);
|
||||||
|
assert!(errors.is_empty());
|
||||||
assert!(!map.mappings["issue:g/p:42"].pending);
|
assert!(!map.mappings["issue:g/p:42"].pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ use std::path::PathBuf;
|
|||||||
/// Get the Mission Control data directory
|
/// Get the Mission Control data directory
|
||||||
pub fn mc_data_dir() -> PathBuf {
|
pub fn mc_data_dir() -> PathBuf {
|
||||||
dirs::data_local_dir()
|
dirs::data_local_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not determine local data directory ($HOME may be unset), falling back to '.'"
|
||||||
|
);
|
||||||
|
PathBuf::from(".")
|
||||||
|
})
|
||||||
.join("mc")
|
.join("mc")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
182
src/hooks/useActions.ts
Normal file
182
src/hooks/useActions.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* useActions -- actions for the focus workflow.
|
||||||
|
*
|
||||||
|
* Handles start, defer, skip, complete actions with:
|
||||||
|
* - Decision logging to backend
|
||||||
|
* - State updates (snooze times, skipped flags)
|
||||||
|
* - URL opening for start action
|
||||||
|
* - Queue advancement via focus store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useFocusStore } from "@/stores/focus-store";
|
||||||
|
import type { DeferDuration, FocusAction } from "@/lib/types";
|
||||||
|
|
||||||
|
/** Minimal item shape needed for actions */
|
||||||
|
export interface ActionItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decision entry sent to backend */
|
||||||
|
interface DecisionEntry {
|
||||||
|
action: string;
|
||||||
|
bead_id: string;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the snooze-until timestamp for a defer action.
|
||||||
|
* All times are calculated in UTC for consistency.
|
||||||
|
*/
|
||||||
|
function calculateSnoozeTime(duration: DeferDuration): string {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (duration) {
|
||||||
|
case "1h":
|
||||||
|
return new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
||||||
|
case "3h":
|
||||||
|
return new Date(now.getTime() + 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
case "tomorrow": {
|
||||||
|
// 9am UTC next day
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||||
|
tomorrow.setUTCHours(9, 0, 0, 0);
|
||||||
|
return tomorrow.toISOString();
|
||||||
|
}
|
||||||
|
case "next_week": {
|
||||||
|
// 9am UTC next Monday
|
||||||
|
const nextWeek = new Date(now);
|
||||||
|
const daysUntilMonday = (8 - nextWeek.getUTCDay()) % 7 || 7;
|
||||||
|
nextWeek.setUTCDate(nextWeek.getUTCDate() + daysUntilMonday);
|
||||||
|
nextWeek.setUTCHours(9, 0, 0, 0);
|
||||||
|
return nextWeek.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a decision to the backend.
|
||||||
|
*/
|
||||||
|
async function logDecision(entry: DecisionEntry): Promise<void> {
|
||||||
|
await invoke("log_decision", { entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseActionsReturn {
|
||||||
|
/** Start working on an item (opens URL if present) */
|
||||||
|
start: (item: ActionItem) => Promise<void>;
|
||||||
|
/** Defer an item for later */
|
||||||
|
defer: (
|
||||||
|
item: ActionItem,
|
||||||
|
duration: DeferDuration,
|
||||||
|
reason: string | null
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Skip an item for today */
|
||||||
|
skip: (item: ActionItem, reason: string | null) => Promise<void>;
|
||||||
|
/** Mark an item as complete */
|
||||||
|
complete: (item: ActionItem, reason: string | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook providing focus workflow actions.
|
||||||
|
*
|
||||||
|
* Each action:
|
||||||
|
* 1. Performs the relevant side effect (open URL, update state)
|
||||||
|
* 2. Logs the decision to the backend
|
||||||
|
* 3. Advances to the next item in the queue
|
||||||
|
*/
|
||||||
|
export function useActions(): UseActionsReturn {
|
||||||
|
const { act } = useFocusStore();
|
||||||
|
|
||||||
|
const start = useCallback(async (item: ActionItem): Promise<void> => {
|
||||||
|
// Open URL in browser if provided
|
||||||
|
if (item.url) {
|
||||||
|
await open(item.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the decision
|
||||||
|
await logDecision({
|
||||||
|
action: "start",
|
||||||
|
bead_id: item.id,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const defer = useCallback(
|
||||||
|
async (
|
||||||
|
item: ActionItem,
|
||||||
|
duration: DeferDuration,
|
||||||
|
reason: string | null
|
||||||
|
): Promise<void> => {
|
||||||
|
const snoozedUntil = calculateSnoozeTime(duration);
|
||||||
|
|
||||||
|
// Update item with snooze time
|
||||||
|
await invoke("update_item", {
|
||||||
|
id: item.id,
|
||||||
|
updates: {
|
||||||
|
snoozed_until: snoozedUntil,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the decision
|
||||||
|
await logDecision({
|
||||||
|
action: "defer",
|
||||||
|
bead_id: item.id,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert duration to FocusAction format and advance queue
|
||||||
|
const actionName: FocusAction = `defer_${duration}` as FocusAction;
|
||||||
|
act(actionName, reason ?? undefined);
|
||||||
|
},
|
||||||
|
[act]
|
||||||
|
);
|
||||||
|
|
||||||
|
const skip = useCallback(
|
||||||
|
async (item: ActionItem, reason: string | null): Promise<void> => {
|
||||||
|
// Mark item as skipped for today
|
||||||
|
await invoke("update_item", {
|
||||||
|
id: item.id,
|
||||||
|
updates: {
|
||||||
|
skipped_today: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the decision
|
||||||
|
await logDecision({
|
||||||
|
action: "skip",
|
||||||
|
bead_id: item.id,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance queue
|
||||||
|
act("skip", reason ?? undefined);
|
||||||
|
},
|
||||||
|
[act]
|
||||||
|
);
|
||||||
|
|
||||||
|
const complete = useCallback(
|
||||||
|
async (item: ActionItem, reason: string | null): Promise<void> => {
|
||||||
|
// Close the bead via backend
|
||||||
|
await invoke("close_bead", {
|
||||||
|
bead_id: item.id,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the decision
|
||||||
|
await logDecision({
|
||||||
|
action: "complete",
|
||||||
|
bead_id: item.id,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance queue
|
||||||
|
act("skip", reason ?? undefined); // Uses skip action to advance
|
||||||
|
},
|
||||||
|
[act]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { start, defer, skip, complete };
|
||||||
|
}
|
||||||
329
tests/hooks/useActions.test.ts
Normal file
329
tests/hooks/useActions.test.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,12 @@ describe("computeStaleness", () => {
|
|||||||
).toISOString();
|
).toISOString();
|
||||||
expect(computeStaleness(tenDaysAgo)).toBe("urgent");
|
expect(computeStaleness(tenDaysAgo)).toBe("urgent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 'normal' for invalid date strings instead of 'urgent'", () => {
|
||||||
|
expect(computeStaleness("not-a-date")).toBe("normal");
|
||||||
|
expect(computeStaleness("")).toBe("normal");
|
||||||
|
expect(computeStaleness("2026-13-99T99:99:99Z")).toBe("normal");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isMcError", () => {
|
describe("isMcError", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user