265 lines
9.4 KiB
TypeScript
265 lines
9.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
adoptDeletedSpaceFallback,
|
|
type MobileBot,
|
|
rpc,
|
|
selectedSpaceId,
|
|
selectSpace,
|
|
} from "./api";
|
|
import {
|
|
canDeleteInboxSpace,
|
|
type InboxSpace,
|
|
removeInboxSpace,
|
|
retryInboxSpaceFallback,
|
|
selectInboxSpace,
|
|
spaceInboxItems,
|
|
} from "./inbox-spaces";
|
|
|
|
vi.mock("./api", () => ({
|
|
adoptDeletedSpaceFallback: vi.fn(),
|
|
rpc: vi.fn(),
|
|
selectedSpaceId: vi.fn(),
|
|
selectSpace: vi.fn(),
|
|
}));
|
|
|
|
function space(overrides: Partial<InboxSpace> = {}): InboxSpace {
|
|
return {
|
|
id: "space-work",
|
|
name: "Work",
|
|
isDefault: false,
|
|
hasContent: false,
|
|
canDelete: true,
|
|
bots: [],
|
|
groups: [],
|
|
botSections: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const bot: MobileBot = {
|
|
id: "bot-1",
|
|
name: "Helper",
|
|
preview: "",
|
|
title: "",
|
|
color: "gray",
|
|
computerMode: "team",
|
|
notifyOnFinish: true,
|
|
threadId: "thread-1",
|
|
pinned: false,
|
|
status: "idle",
|
|
sectionId: null,
|
|
archivedAt: null,
|
|
unread: false,
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
modelProvider: null,
|
|
modelId: null,
|
|
thinkingLevel: null,
|
|
};
|
|
|
|
describe("spaceInboxItems", () => {
|
|
it("keeps an empty space visible and selectable by its own id", () => {
|
|
const empty = space();
|
|
expect(spaceInboxItems([empty])).toEqual([
|
|
{ type: "heading", key: empty.id, title: empty.name, space: empty },
|
|
]);
|
|
});
|
|
|
|
it("keeps the only empty default space visible without offering deletion", () => {
|
|
const personal = space({ isDefault: true, canDelete: true });
|
|
const items = spaceInboxItems([personal]);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({ type: "heading", space: personal });
|
|
expect(canDeleteInboxSpace(personal)).toBe(false);
|
|
});
|
|
|
|
it("renders space actions once, separate from pinned and named bot sections", () => {
|
|
const work = space({
|
|
bots: [
|
|
{ ...bot, pinned: true },
|
|
{ ...bot, id: "bot-2", sectionId: "section-1" },
|
|
],
|
|
botSections: [
|
|
{
|
|
id: "section-1",
|
|
name: "Research",
|
|
position: 0,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
const headings = spaceInboxItems([work]).filter((item) => item.type === "heading");
|
|
expect(headings.map((item) => item.title)).toEqual(["Work", "Pinned", "Research"]);
|
|
expect(headings.filter((item) => item.space)).toEqual([
|
|
{ type: "heading", key: work.id, title: work.name, space: work },
|
|
]);
|
|
});
|
|
|
|
it("keeps populated default-space chrome minimal and retains group rows", () => {
|
|
const group = {
|
|
id: "group-1",
|
|
name: "Planning",
|
|
preview: "",
|
|
pinned: false,
|
|
sectionId: null,
|
|
archivedAt: null,
|
|
unread: false,
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
members: [],
|
|
};
|
|
const items = spaceInboxItems([space({ isDefault: true, bots: [bot], groups: [group] })]);
|
|
expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
|
|
});
|
|
|
|
it("lists a project's main conversation instead of its main bot", () => {
|
|
const main = {
|
|
id: "group-main",
|
|
name: "Chief",
|
|
kind: "main" as const,
|
|
preview: "",
|
|
pinned: false,
|
|
sectionId: null,
|
|
archivedAt: null,
|
|
unread: false,
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
members: [],
|
|
};
|
|
const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" };
|
|
const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]);
|
|
expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([
|
|
"Work",
|
|
"bot",
|
|
"group",
|
|
]);
|
|
expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false);
|
|
});
|
|
|
|
it("keeps empty spaces beside a populated space", () => {
|
|
const personal = space({ id: "personal", isDefault: true, bots: [bot] });
|
|
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([
|
|
"heading",
|
|
"bot",
|
|
"heading",
|
|
]);
|
|
});
|
|
|
|
it.each([undefined, false])("hides deletion unless ownership is explicit (%s)", (canDelete) => {
|
|
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
|
|
});
|
|
|
|
it("offers deletion for an explicitly authorized non-default space", () => {
|
|
expect(canDeleteInboxSpace(space())).toBe(true);
|
|
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
|
|
});
|
|
|
|
it("deduplicates same-named projects into a single project entry", () => {
|
|
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
|
|
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
|
|
const items = spaceInboxItems([first, second]);
|
|
const headings = items.filter((item) => item.type === "heading" && item.space);
|
|
expect(headings).toHaveLength(1);
|
|
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
|
|
});
|
|
});
|
|
|
|
describe("removeInboxSpace", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(selectedSpaceId).mockReturnValue("space-work");
|
|
vi.mocked(rpc).mockResolvedValue({ ok: true, activeSpaceId: "personal" });
|
|
vi.mocked(selectSpace).mockResolvedValue(true);
|
|
vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(true);
|
|
});
|
|
|
|
it("awaits saving the server fallback before refreshing a deleted current space", async () => {
|
|
let finishSelection!: (saved: boolean) => void;
|
|
vi.mocked(selectSpace).mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
finishSelection = resolve;
|
|
}),
|
|
);
|
|
const refresh = vi.fn(async () => undefined);
|
|
const removing = removeInboxSpace("space-work", refresh);
|
|
await vi.waitFor(() => expect(selectSpace).toHaveBeenCalledWith("personal"));
|
|
expect(refresh).not.toHaveBeenCalled();
|
|
finishSelection(true);
|
|
await expect(removing).resolves.toBeNull();
|
|
expect(rpc).toHaveBeenCalledWith("spaces/remove", { spaceId: "space-work" });
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("keeps the selection when deleting another space", async () => {
|
|
vi.mocked(selectedSpaceId).mockReturnValue("personal");
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(removeInboxSpace("space-work", refresh)).resolves.toBeNull();
|
|
expect(selectSpace).not.toHaveBeenCalled();
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("adopts and refreshes the server fallback when direct persistence fails", async () => {
|
|
vi.mocked(selectSpace).mockResolvedValue(false);
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(removeInboxSpace("space-work", refresh)).resolves.toBeNull();
|
|
expect(adoptDeletedSpaceFallback).toHaveBeenCalledWith("personal");
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
expect(rpc).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("retains a retry when neither fallback recovery write is durable", async () => {
|
|
vi.mocked(selectSpace).mockResolvedValue(false);
|
|
vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(false);
|
|
const refresh = vi.fn(async () => undefined);
|
|
const recoveryId = await removeInboxSpace("space-work", refresh);
|
|
expect(recoveryId).toBe("personal");
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(true);
|
|
await expect(retryInboxSpaceFallback(recoveryId!, refresh)).resolves.toBe(true);
|
|
expect(adoptDeletedSpaceFallback).toHaveBeenCalledTimes(2);
|
|
expect(adoptDeletedSpaceFallback).toHaveBeenLastCalledWith("personal");
|
|
expect(selectSpace).toHaveBeenCalledOnce();
|
|
expect(refresh).toHaveBeenCalledTimes(2);
|
|
expect(rpc).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("keeps the retry visible when fallback persistence still fails", async () => {
|
|
vi.mocked(adoptDeletedSpaceFallback).mockResolvedValue(false);
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(retryInboxSpaceFallback("personal", refresh)).resolves.toBe(false);
|
|
expect(refresh).not.toHaveBeenCalled();
|
|
expect(rpc).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("selects an empty inbox without creating a bot or starting onboarding", async () => {
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(selectInboxSpace("space-work", refresh)).resolves.toBe(true);
|
|
expect(selectSpace).toHaveBeenCalledWith("space-work");
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
expect(rpc).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not refresh when selecting an empty space fails", async () => {
|
|
vi.mocked(selectSpace).mockResolvedValue(false);
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(selectInboxSpace("space-work", refresh)).resolves.toBe(false);
|
|
expect(refresh).not.toHaveBeenCalled();
|
|
expect(rpc).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("also adopts the fallback when native selection throws", async () => {
|
|
vi.mocked(selectSpace).mockRejectedValue(new Error("storage unavailable"));
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(removeInboxSpace("space-work", refresh)).resolves.toBeNull();
|
|
expect(adoptDeletedSpaceFallback).toHaveBeenCalledWith("personal");
|
|
expect(refresh).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("leaves selection and inbox unchanged when deletion is rejected", async () => {
|
|
vi.mocked(rpc).mockRejectedValue(new Error("Space is not empty"));
|
|
const refresh = vi.fn(async () => undefined);
|
|
await expect(removeInboxSpace("space-work", refresh)).rejects.toThrow("Space is not empty");
|
|
expect(selectSpace).not.toHaveBeenCalled();
|
|
expect(refresh).not.toHaveBeenCalled();
|
|
});
|
|
});
|