Next up, Today and Lists replace the six-button launcher. Lists is a 2x2 board (Next up / Waiting / Someday / Projects): full scrolling panes on a desktop window, preview cards on a phone, each header opening its list and each project opening its tasks. The Needs-attention card collapses to one row of status pills under the title. Review and its screen are gone; Done and Trash move under the Settings gear. v1.24. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW5iVXkZ2JBHgyweMCRymY
127 lines
5.1 KiB
TypeScript
127 lines
5.1 KiB
TypeScript
// Store unit tests — run with `bun test src/lib`. Expo/native modules are
|
|
// mocked so the local-first mutations run in plain Bun.
|
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
|
const storage = new Map<string, string>();
|
|
mock.module("@react-native-async-storage/async-storage", () => ({
|
|
default: {
|
|
getItem: async (k: string) => storage.get(k) ?? null,
|
|
setItem: async (k: string, v: string) => void storage.set(k, v),
|
|
},
|
|
}));
|
|
let seq = 0;
|
|
mock.module("expo-crypto", () => ({
|
|
randomUUID: () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`,
|
|
}));
|
|
mock.module("./api", () => ({ api: {} }));
|
|
mock.module("./config", () => ({ getConfig: async () => ({ token: "" }) }));
|
|
|
|
const store = await import("./store");
|
|
|
|
beforeEach(async () => {
|
|
storage.clear();
|
|
await store.hydrate();
|
|
store.setArea("personal");
|
|
});
|
|
|
|
const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!;
|
|
|
|
describe("completeTask", () => {
|
|
test("marks done and undo restores the original status in place", () => {
|
|
const a = store.createTask({ title: "a", status: "waiting", sort_order: 5 });
|
|
const undo = store.completeTask(a.id);
|
|
expect(find(a.id).status).toBe("done");
|
|
expect(find(a.id).completed_at).not.toBeNull();
|
|
undo!();
|
|
expect(find(a.id).status).toBe("waiting");
|
|
expect(find(a.id).completed_at).toBeNull();
|
|
expect(find(a.id).sort_order).toBe(5);
|
|
});
|
|
|
|
test("revertTo overrides the status undo returns to", () => {
|
|
const a = store.createTask({ title: "a", status: "next" });
|
|
store.completeTask(a.id, "someday")!();
|
|
expect(find(a.id).status).toBe("someday");
|
|
});
|
|
|
|
test("undo of a repeating task trashes the spawned occurrence", () => {
|
|
const a = store.createTask({ title: "rent", repeat: "monthly", due_date: "2026-09-01" });
|
|
const known = new Set(store.getState().tasks.map((t) => t.id));
|
|
const undo = store.completeTask(a.id)!;
|
|
const spawned = store.getState().tasks.filter((t) => !known.has(t.id));
|
|
expect(spawned).toHaveLength(1);
|
|
expect(spawned[0].status).toBe("next");
|
|
undo();
|
|
expect(find(a.id).status).toBe("next");
|
|
expect(find(spawned[0].id).status).toBe("trashed");
|
|
});
|
|
|
|
test("returns null for an already-done or unknown task", () => {
|
|
const a = store.createTask({ title: "a", status: "done" });
|
|
expect(store.completeTask(a.id)).toBeNull();
|
|
expect(store.completeTask("nope")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("area lens", () => {
|
|
test("new rows are born into the active area and selectors filter by it", () => {
|
|
const home = store.createTask({ title: "home" });
|
|
store.setArea("work");
|
|
const office = store.createTask({ title: "office" });
|
|
const proj = store.createProject({ name: "Q4" });
|
|
expect(home.area).toBe("personal");
|
|
expect(office.area).toBe("work");
|
|
expect(proj.area).toBe("work");
|
|
|
|
// The store is module state shared across tests, so assert on membership
|
|
// and on every visible row's area rather than on exact lists.
|
|
const ids = (s: ReturnType<typeof store.getState>) => store.tasksByStatus(s, "next").map((t) => t.id);
|
|
let s = store.getState();
|
|
expect(ids(s)).toContain(office.id);
|
|
expect(ids(s)).not.toContain(home.id);
|
|
expect(store.tasksByStatus(s, "next").every((t) => t.area === "work")).toBe(true);
|
|
expect(store.activeProjects(s).map((p) => p.id)).toEqual([proj.id]);
|
|
|
|
store.setArea("personal");
|
|
s = store.getState();
|
|
expect(ids(s)).toContain(home.id);
|
|
expect(ids(s)).not.toContain(office.id);
|
|
expect(store.activeProjects(s).some((p) => p.id === proj.id)).toBe(false);
|
|
});
|
|
|
|
test("area persists across hydrate", async () => {
|
|
store.setArea("work");
|
|
expect(storage.get("area_v1")).toBe("work");
|
|
});
|
|
|
|
test("rows cached without an area count as personal", () => {
|
|
// Simulate a pre-migration cache row.
|
|
const legacy = store.createTask({ title: "old" });
|
|
delete (legacy as { area?: string }).area;
|
|
store.setArea("work");
|
|
expect(store.todayTasks(store.getState()).overdue).toEqual([]);
|
|
store.setArea("personal");
|
|
expect(store.tasksByStatus(store.getState(), "next").some((t) => t.id === legacy.id)).toBe(true);
|
|
});
|
|
|
|
test("today and contexts respect the lens", () => {
|
|
store.createTask({ title: "p", due_date: "2000-01-01", context: "@home" });
|
|
store.setArea("work");
|
|
store.createTask({ title: "w", due_date: "2000-01-01", context: "@office" });
|
|
const s = store.getState();
|
|
expect(store.todayTasks(s).overdue.every((t) => t.area === "work")).toBe(true);
|
|
expect(store.todayTasks(s).overdue.map((t) => t.title)).toContain("w");
|
|
expect(store.todayTasks(s).overdue.map((t) => t.title)).not.toContain("p");
|
|
expect(store.knownContexts(s)).toEqual(["@office"]);
|
|
});
|
|
|
|
test("completing a repeating work task spawns into work", () => {
|
|
store.setArea("work");
|
|
const t = store.createTask({ title: "standup", repeat: "daily", due_date: "2000-01-01" });
|
|
store.setArea("personal");
|
|
store.completeTask(t.id);
|
|
const spawned = store.getState().tasks.find((x) => x.title === "standup" && x.status === "next")!;
|
|
expect(spawned.area).toBe("work");
|
|
});
|
|
});
|