Add Vitest suite covering the smoke-test fixes

Extracts two pure helpers out of Svelte components so they can be
exercised without the reactive runtime, and adds component tests for
ConfirmDialog's Escape-handling behavior.

- apps/tauri/src/lib/grouping.ts (new): `groupTasksByDate` lifted out of
  the `groupedPendingTasks` $derived in the app store.
- apps/tauri/src/lib/paths.ts (new): `workspaceNameFromPath` lifted out
  of SetupScreen.handleOpen.
- apps/tauri/src/lib/grouping.test.ts: 8 cases — "No Date" placed last
  (regression), full bucket ordering, empty input, within-bucket
  stable sort, earlier-today stays in Today, multi-task same-day,
  No Date preserves insertion order.
- apps/tauri/src/lib/paths.test.ts: 8 cases — POSIX/Windows/mixed
  separators, trailing slash regression ("…/Tasks/" → "Tasks"), empty
  and root-only fallback, names with spaces.
- apps/tauri/src/lib/components/ConfirmDialog.test.ts: 6 cases —
  renders message/detail/custom confirm text, Cancel/Confirm fire the
  right callbacks, Escape calls oncancel and does NOT reach an outer
  window listener (regression), non-Escape keys are ignored, and the
  module-level open-count increments/decrements correctly (including
  when two dialogs are mounted at once).

Test harness: Vitest + jsdom + @testing-library/svelte. `npm test`
runs the suite; `resolve.conditions` is set to "browser" under VITEST
so Svelte resolves its client entry and mount() works.

23/23 tests pass. cargo check, cargo test -p onyx-core (162/162),
and npm run build all still green.
This commit is contained in:
Claude 2026-04-17 14:33:12 +00:00
parent 8a04895270
commit 67ac43e527
No known key found for this signature in database
11 changed files with 1543 additions and 65 deletions

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,23 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"jsdom": "^29.0.2",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.4"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",

View file

@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import ConfirmDialog, { isConfirmDialogOpen } from "./ConfirmDialog.svelte";
beforeEach(() => {
cleanup();
});
describe("ConfirmDialog", () => {
it("renders the message, detail and custom confirm label", () => {
render(ConfirmDialog, {
message: "Delete task?",
detail: "This cannot be undone.",
confirmText: "Delete",
onconfirm: vi.fn(),
oncancel: vi.fn(),
});
expect(screen.getByText("Delete task?")).toBeInTheDocument();
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("fires oncancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const oncancel = vi.fn();
render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel,
});
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(oncancel).toHaveBeenCalledTimes(1);
});
it("fires onconfirm when Confirm is clicked and not oncancel", async () => {
const user = userEvent.setup();
const onconfirm = vi.fn();
const oncancel = vi.fn();
render(ConfirmDialog, {
message: "Delete?",
confirmText: "Delete",
onconfirm,
oncancel,
});
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onconfirm).toHaveBeenCalledTimes(1);
expect(oncancel).not.toHaveBeenCalled();
});
it("cancels and stops propagation on Escape (regression: used to bubble and pop task detail)", async () => {
const oncancel = vi.fn();
// An outer bubble-phase listener emulates TasksScreen's svelte:window
// Escape handler. If the dialog leaks Escape, this spy fires too.
const outer = vi.fn();
window.addEventListener("keydown", outer);
try {
render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel,
});
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
expect(oncancel).toHaveBeenCalledTimes(1);
expect(outer).not.toHaveBeenCalled();
} finally {
window.removeEventListener("keydown", outer);
}
});
it("ignores non-Escape keydowns", async () => {
const oncancel = vi.fn();
render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel,
});
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
expect(oncancel).not.toHaveBeenCalled();
});
it("increments the open-count singleton so parent Escape handlers can defer", () => {
expect(isConfirmDialogOpen()).toBe(false);
const { unmount } = render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel: vi.fn(),
});
expect(isConfirmDialogOpen()).toBe(true);
unmount();
expect(isConfirmDialogOpen()).toBe(false);
});
it("tracks multiple concurrently-mounted dialogs and releases on unmount", () => {
const a = render(ConfirmDialog, { message: "A?", onconfirm: vi.fn(), oncancel: vi.fn() });
const b = render(ConfirmDialog, { message: "B?", onconfirm: vi.fn(), oncancel: vi.fn() });
expect(isConfirmDialogOpen()).toBe(true);
a.unmount();
expect(isConfirmDialogOpen()).toBe(true);
b.unmount();
expect(isConfirmDialogOpen()).toBe(false);
});
});

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { groupTasksByDate } from "./grouping";
import type { Task } from "./types";
// 2026-04-17 12:00 local time — "today" in the fixtures below.
const NOW = new Date(2026, 3, 17, 12, 0, 0);
function task(partial: Partial<Task> & { id: string }): Task {
return {
id: partial.id,
title: partial.title ?? partial.id,
description: "",
status: "backlog",
date: partial.date ?? null,
has_time: partial.has_time ?? false,
version: 1,
parent_id: null,
...partial,
};
}
describe("groupTasksByDate", () => {
it("returns an empty array when there are no pending tasks", () => {
expect(groupTasksByDate([], NOW)).toEqual([]);
});
it("puts 'No Date' last — regression: was first, burying urgent tasks", () => {
const tasks = [
task({ id: "overdue", date: "2026-04-15T00:00:00Z" }),
task({ id: "no-date" }),
task({ id: "today", date: "2026-04-17T09:00:00Z" }),
];
const labels = groupTasksByDate(tasks, NOW).map((g) => g.label);
expect(labels).toEqual(["Overdue", "Today", "No Date"]);
});
it("orders dated buckets: Overdue, Today, Tomorrow, future…, then No Date", () => {
const tasks = [
task({ id: "nd1" }),
task({ id: "future", date: "2026-04-20T00:00:00Z" }),
task({ id: "tomorrow", date: "2026-04-18T00:00:00Z" }),
task({ id: "today", date: "2026-04-17T09:00:00Z" }),
task({ id: "overdue", date: "2026-04-10T00:00:00Z" }),
];
const labels = groupTasksByDate(tasks, NOW).map((g) => g.label);
expect(labels[0]).toBe("Overdue");
expect(labels[1]).toBe("Today");
expect(labels[2]).toBe("Tomorrow");
// One future day label between tomorrow and No Date
expect(labels[labels.length - 1]).toBe("No Date");
expect(labels).toHaveLength(5);
});
it("drops empty buckets", () => {
const tasks = [task({ id: "t1", date: "2026-04-17T08:00:00Z" })];
expect(groupTasksByDate(tasks, NOW).map((g) => g.label)).toEqual(["Today"]);
});
it("sorts tasks within a bucket by due time ascending, stable on ties", () => {
const tasks = [
task({ id: "b", date: "2026-04-17T15:00:00Z", has_time: true }),
task({ id: "a", date: "2026-04-17T09:00:00Z", has_time: true }),
task({ id: "c", date: "2026-04-17T15:00:00Z", has_time: true }),
];
const today = groupTasksByDate(tasks, NOW).find((g) => g.label === "Today")!;
expect(today.tasks.map((t) => t.id)).toEqual(["a", "b", "c"]);
});
it("places a task with today's date but time before 'now' in the Today bucket (not Overdue)", () => {
const tasks = [task({ id: "earlier-today", date: "2026-04-17T08:00:00Z" })];
const groups = groupTasksByDate(tasks, NOW);
expect(groups.map((g) => g.label)).toEqual(["Today"]);
});
it("preserves No Date order as given by the caller", () => {
const tasks = [
task({ id: "z" }),
task({ id: "a" }),
task({ id: "m" }),
];
const nd = groupTasksByDate(tasks, NOW).find((g) => g.label === "No Date")!;
expect(nd.tasks.map((t) => t.id)).toEqual(["z", "a", "m"]);
});
it("groups multiple tasks on the same future day under one label", () => {
const tasks = [
task({ id: "f1", date: "2026-04-25T09:00:00Z", has_time: true }),
task({ id: "f2", date: "2026-04-25T14:00:00Z", has_time: true }),
];
const groups = groupTasksByDate(tasks, NOW);
const future = groups.find((g) => g.date?.getDate() === 25);
expect(future).toBeDefined();
expect(future!.tasks.map((t) => t.id)).toEqual(["f1", "f2"]);
// And it comes before No Date (which is absent here).
expect(groups).toHaveLength(1);
});
});

View file

@ -0,0 +1,70 @@
import type { Task } from "./types";
export type TaskGroup = { label: string; tasks: Task[]; date: Date | null };
/**
* Group pending tasks into date buckets for the "group by date" view.
*
* Order:
* Overdue Today Tomorrow future days (chronological) No Date
*
* Within each dated bucket tasks sort by due date+time ascending, with the
* original `pendingTasks` index as a stable tiebreaker. "No Date" preserves
* the caller-supplied order.
*/
export function groupTasksByDate(pendingTasks: Task[], now: Date = new Date()): TaskGroup[] {
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(todayStart.getDate() + 1);
const overdue: Task[] = [];
const today: Task[] = [];
const tomorrow: Task[] = [];
const futureByDay = new Map<string, { date: Date; tasks: Task[] }>();
const noDate: Task[] = [];
for (const task of pendingTasks) {
if (!task.date) {
noDate.push(task);
} else {
const d = new Date(task.date);
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (dayStart < todayStart) overdue.push(task);
else if (dayStart.getTime() === todayStart.getTime()) today.push(task);
else if (dayStart.getTime() === tomorrowStart.getTime()) tomorrow.push(task);
else {
const key = dayStart.toISOString();
if (!futureByDay.has(key)) futureByDay.set(key, { date: dayStart, tasks: [] });
futureByDay.get(key)!.tasks.push(task);
}
}
}
const taskOrderIndex = new Map(pendingTasks.map((t, i) => [t.id, i]));
const sortByDue = (a: Task, b: Task) => {
const dateDiff = new Date(a.date!).getTime() - new Date(b.date!).getTime();
if (dateDiff !== 0) return dateDiff;
return (taskOrderIndex.get(a.id) ?? 0) - (taskOrderIndex.get(b.id) ?? 0);
};
overdue.sort(sortByDue);
today.sort(sortByDue);
tomorrow.sort(sortByDue);
const groups: TaskGroup[] = [];
if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null });
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart });
const currentYear = now.getFullYear();
for (const [, { date, tasks }] of [...futureByDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
tasks.sort(sortByDue);
const opts: Intl.DateTimeFormatOptions = date.getFullYear() !== currentYear
? { weekday: "short", month: "short", day: "numeric", year: "numeric" }
: { weekday: "short", month: "short", day: "numeric" };
groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date });
}
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
return groups;
}

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { workspaceNameFromPath } from "./paths";
describe("workspaceNameFromPath", () => {
it("returns the last path component of a POSIX path", () => {
expect(workspaceNameFromPath("/home/me/Tasks")).toBe("Tasks");
});
it("strips a trailing slash (regression: used to fall back to 'workspace')", () => {
expect(workspaceNameFromPath("/home/me/Tasks/")).toBe("Tasks");
});
it("strips multiple trailing slashes", () => {
expect(workspaceNameFromPath("/home/me/Tasks///")).toBe("Tasks");
});
it("handles Windows-style backslash paths", () => {
expect(workspaceNameFromPath("C:\\Users\\me\\Tasks")).toBe("Tasks");
});
it("strips a trailing backslash on Windows paths", () => {
expect(workspaceNameFromPath("C:\\Users\\me\\Tasks\\")).toBe("Tasks");
});
it("handles mixed separators", () => {
expect(workspaceNameFromPath("C:\\Users/me\\Tasks")).toBe("Tasks");
});
it("falls back to 'workspace' when the path has no usable tail", () => {
expect(workspaceNameFromPath("/")).toBe("workspace");
expect(workspaceNameFromPath("\\")).toBe("workspace");
expect(workspaceNameFromPath("")).toBe("workspace");
});
it("preserves names with spaces", () => {
expect(workspaceNameFromPath("/home/me/My Tasks/")).toBe("My Tasks");
});
});

View file

@ -0,0 +1,9 @@
/**
* Derive a workspace display name from a folder path picked via the file
* dialog. Handles both `/` and `\` separators and tolerates trailing
* separators (e.g. `"/home/me/Tasks/"` `"Tasks"`, not `"workspace"`).
*/
export function workspaceNameFromPath(folder: string): string {
const parts = folder.replace(/[\\/]+$/, "").split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
}

View file

@ -5,6 +5,7 @@
import { app } from "../stores/app.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { workspaceNameFromPath } from "../paths";
let { cancellable = false }: { cancellable?: boolean } = $props();
@ -71,11 +72,7 @@
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
const folder = selected as string;
// Strip trailing separators before splitting so a path like "/home/me/Tasks/"
// yields "Tasks" instead of an empty tail that falls through to "workspace".
const parts = folder.replace(/[\\/]+$/, "").split(/[\\/]/);
const wsName = parts[parts.length - 1] || "workspace";
await app.addWorkspace(wsName, folder);
await app.addWorkspace(workspaceNameFromPath(folder), folder);
}
// ── WebDAV handlers ───────────────────────────────────────────────

View file

@ -8,6 +8,7 @@ import type {
Screen,
SyncResult,
} from "../types";
import { groupTasksByDate, type TaskGroup } from "../grouping";
// Listen for file system changes from the backend watcher.
listen("fs-changed", () => {
@ -52,65 +53,9 @@ let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null);
let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id));
let completedTasks = $derived(tasks.filter((t) => t.status === "completed" && !t.parent_id));
type TaskGroup = { label: string; tasks: Task[]; date: Date | null };
let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
if (!activeList?.group_by_date) return null;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(todayStart.getDate() + 1);
const overdue: Task[] = [];
const today: Task[] = [];
const tomorrow: Task[] = [];
const futureByDay = new Map<string, { date: Date; tasks: Task[] }>();
const noDate: Task[] = [];
for (const task of pendingTasks) {
if (!task.date) {
noDate.push(task);
} else {
const d = new Date(task.date);
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (dayStart < todayStart) overdue.push(task);
else if (dayStart.getTime() === todayStart.getTime()) today.push(task);
else if (dayStart.getTime() === tomorrowStart.getTime()) tomorrow.push(task);
else {
const key = dayStart.toISOString();
if (!futureByDay.has(key)) futureByDay.set(key, { date: dayStart, tasks: [] });
futureByDay.get(key)!.tasks.push(task);
}
}
}
const taskOrderIndex = new Map(pendingTasks.map((t, i) => [t.id, i]));
const sortByDue = (a: Task, b: Task) => {
const dateDiff = new Date(a.date!).getTime() - new Date(b.date!).getTime();
if (dateDiff !== 0) return dateDiff;
return (taskOrderIndex.get(a.id) ?? 0) - (taskOrderIndex.get(b.id) ?? 0);
};
overdue.sort(sortByDue);
today.sort(sortByDue);
tomorrow.sort(sortByDue);
const groups: TaskGroup[] = [];
if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null });
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart });
const currentYear = now.getFullYear();
for (const [, { date, tasks }] of [...futureByDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
tasks.sort(sortByDue);
const opts: Intl.DateTimeFormatOptions = date.getFullYear() !== currentYear
? { weekday: "short", month: "short", day: "numeric", year: "numeric" }
: { weekday: "short", month: "short", day: "numeric" };
groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date });
}
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
return groups;
return groupTasksByDate(pendingTasks);
});
// Build a map of parent_id -> children for subtask hierarchy

View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View file

@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
@ -14,4 +15,17 @@ export default defineConfig({
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,svelte}"],
// Resolve Svelte's client (browser) entry under Vitest — without the
// browser condition mount() picks up Svelte's SSR export and throws
// lifecycle_function_unavailable.
server: { deps: { inline: ["@testing-library/svelte"] } },
},
resolve: {
conditions: process.env.VITEST ? ["browser"] : [],
},
});