1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 buildDraftActionMenuItems,
5 buildThreadActionMenuItems,
6 type ThreadActionMenuState,
7} from "./threadActionMenu.logic";
8
9const baseState: ThreadActionMenuState = {
10 canOperate: true,
11 canOrganize: true,
12 branch: null,
13 projectFilter: null,
14 isPinned: false,
15 isSettled: false,
16 autoSettleEnabled: true,
17 isSnoozed: false,
18 canSnoozeNow: true,
19 isRegeneratingTitle: false,
20 isRunning: false,
21 supports: {
22 settlement: true,
23 autoSettleOptOut: true,
24 snooze: true,
25 pinning: true,
26 titleRegeneration: true,
27 },
28 snoozePresets: [
29 { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },
30 ],
31};
32
33function ids(state: ThreadActionMenuState): string[] {
34 return buildThreadActionMenuItems(state).map((item) => item.id);
35}
36
37function allIds(state: ThreadActionMenuState): string[] {
38 const flatten = (items: ReturnType<typeof buildThreadActionMenuItems>): string[] =>
39 items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]);
40 return flatten(buildThreadActionMenuItems(state));
41}
42
43describe("buildThreadActionMenuItems", () => {
44 it.each([false, true])(
45 "disables both lifecycle directions without permission (reversed: %s)",
46 (reversed) => {
47 const items = buildThreadActionMenuItems({
48 ...baseState,
49 canOperate: false,
50 canOrganize: false,
51 isPinned: reversed,
52 isSettled: reversed,
53 isSnoozed: reversed,
54 });
55 const expected = reversed
56 ? [
57 "unpin",
58 "unsettle",
59 "unsnooze",
60 "rename",
61 "regenerate-title",
62 "auto-settle",
63 "archive",
64 "delete",
65 ]
66 : [
67 "pin",
68 "settle",
69 "snooze",
70 "rename",
71 "regenerate-title",
72 "auto-settle",
73 "archive",
74 "delete",
75 ];
76 expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual(expected);
77 expect(
78 items.find((item) => item.id === "snooze")?.children?.every((child) => child.disabled) ??
79 true,
80 ).toBe(true);
81 },
82 );
83
84 it("preserves local actions and restores mutations after a grant", () => {
85 const denied = buildThreadActionMenuItems({
86 ...baseState,
87 canOperate: false,
88 canOrganize: false,
89 branch: "main",
90 });
91 expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([
92 "new-thread-on-branch",
93 "mark-unread",
94 "copy",
95 "project-settings",
96 ]);
97 const allowed = buildThreadActionMenuItems({ ...baseState, canOperate: true });
98 expect(allowed.every((item) => !item.disabled)).toBe(true);
99 });
100
101 it("hides lifecycle items when the environment lacks the capabilities", () => {
102 expect(
103 ids({
104 ...baseState,
105 supports: {
106 settlement: false,
107 autoSettleOptOut: false,
108 snooze: false,
109 pinning: false,
110 titleRegeneration: false,
111 },
112 }),
113 ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]);
114 });
115
116 it("groups project settings with utility actions before archive", () => {
117 const items = buildThreadActionMenuItems(baseState);
118 const copyIndex = items.findIndex((item) => item.id === "copy");
119 expect(items[copyIndex + 1]).toMatchObject({
120 id: "project-settings",
121 label: "Project settings",
122 icon: "settings",
123 });
124 expect(items[copyIndex + 2]?.id).toBe("archive");
125 });
126
127 it("offers project filtering only for surfaces with a scoped thread list", () => {
128 expect(ids(baseState)).not.toContain("filter-by-project");
129 expect(
130 buildThreadActionMenuItems({
131 ...baseState,
132 projectFilter: { label: "Beta Project", isActive: false },
133 }).find((item) => item.id === "filter-by-project"),
134 ).toMatchObject({ label: "Filter by Beta Project", icon: "folder-tree" });
135 });
136
137 it("offers the way back to all projects once the list is scoped", () => {
138 const items = buildThreadActionMenuItems({
139 ...baseState,
140 projectFilter: { label: "Beta Project", isActive: true },
141 });
142 const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project");
143 expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" });
144 expect(items[filterIndex - 1]?.id).toBe("mark-unread");
145 expect(items[filterIndex + 1]?.id).toBe("auto-settle");
146 });
147
148 it("includes branch items only for threads with a branch", () => {
149 const withBranch = allIds({ ...baseState, branch: "feat/menu" });
150 expect(withBranch).toContain("new-thread-on-branch");
151 expect(withBranch).toContain("copy-branch");
152 expect(allIds(baseState)).not.toContain("new-thread-on-branch");
153 expect(allIds(baseState)).not.toContain("copy-branch");
154 });
155
156 it("flips lifecycle labels with thread state", () => {
157 expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual(
158 expect.arrayContaining(["unpin", "unsettle", "unsnooze"]),
159 );
160 expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"]));
161 });
162
163 it("offers auto-settle as a submenu with the current option checked", () => {
164 const find = (state: ThreadActionMenuState) =>
165 buildThreadActionMenuItems(state).find((item) => item.id === "auto-settle");
166 const on = find(baseState);
167 expect(on?.label).toBe("Auto-settle behavior");
168 expect(on?.children?.map((child) => [child.id, child.checked])).toEqual([
169 ["auto-settle:enabled", true],
170 ["auto-settle:disabled", false],
171 ]);
172 const off = find({ ...baseState, autoSettleEnabled: false });
173 expect(off?.children?.map((child) => child.checked)).toEqual([false, true]);
174 // Sits with the per-thread settings after Mark unread, not the lifecycle verbs.
175 const items = buildThreadActionMenuItems(baseState);
176 expect(items[items.findIndex((item) => item.id === "mark-unread") + 1]?.id).toBe("auto-settle");
177 expect(
178 ids({ ...baseState, supports: { ...baseState.supports, autoSettleOptOut: false } }),
179 ).not.toContain("auto-settle");
180 });
181
182 it("disables snooze when the thread cannot snooze, keeping presets visible", () => {
183 const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find(
184 (item) => item.id === "snooze",
185 );
186 expect(snooze?.disabled).toBe(true);
187 expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour", "snooze:custom"]);
188 });
189
190 it("disables title regeneration while one is in flight", () => {
191 const item = buildThreadActionMenuItems({ ...baseState, isRegeneratingTitle: true }).find(
192 (candidate) => candidate.id === "regenerate-title",
193 );
194 expect(item).toMatchObject({ label: "Regenerating…", disabled: true });
195 });
196
197 it("marks delete as destructive and keeps it last", () => {
198 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
199 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
200 });
201 it("offers archive as a non-destructive action right before delete", () => {
202 const items = buildThreadActionMenuItems(baseState);
203 const archiveItem = items.at(-2);
204 expect(archiveItem?.id).toBe("archive");
205 expect(archiveItem?.icon).toBe("archive");
206 expect(archiveItem?.separatorBefore).toBe(true);
207 expect(archiveItem?.destructive).toBeFalsy();
208 expect(items.at(-1)?.id).toBe("delete");
209 });
210
211 it("keeps archive available even when the environment lacks every other capability", () => {
212 expect(
213 ids({
214 ...baseState,
215 supports: {
216 settlement: false,
217 autoSettleOptOut: false,
218 snooze: false,
219 pinning: false,
220 titleRegeneration: false,
221 },
222 }),
223 ).toContain("archive");
224 });
225
226 it("disables archive while the thread is running", () => {
227 const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find(
228 (item) => item.id === "archive",
229 );
230 expect(archiveItem?.disabled).toBe(true);
231 });
232});
233
234describe("buildDraftActionMenuItems", () => {
235 it("offers only the copy values the draft has", () => {
236 const items = buildDraftActionMenuItems({ hasPath: false, hasBranch: true, hasProject: true });
237 expect(items[0]).toMatchObject({ id: "copy", disabled: false });
238 expect(items[0]?.children?.map((item) => item.id)).toEqual(["copy-branch"]);
239
240 const noCopy = buildDraftActionMenuItems({
241 hasPath: false,
242 hasBranch: false,
243 hasProject: true,
244 });
245 expect(noCopy[0]).toMatchObject({ id: "copy", disabled: true, children: [] });
246 });
247
248 it("drops project settings without a project and keeps discard last", () => {
249 const items = buildDraftActionMenuItems({ hasPath: true, hasBranch: false, hasProject: false });
250 expect(items.map((item) => item.id)).toEqual(["copy", "discard"]);
251 expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true });
252 });
253});
254
255it("permits central organization while execution and deletion remain disabled", () => {
256 const items = buildThreadActionMenuItems({ ...baseState, canOperate: false, canOrganize: true });
257 for (const id of ["pin", "settle", "snooze", "auto-settle"]) {
258 expect(items.find((item) => item.id === id)?.disabled).not.toBe(true);
259 }
260 for (const id of ["rename", "regenerate-title", "archive", "delete"]) {
261 expect(items.find((item) => item.id === id)?.disabled).toBe(true);
262 }
263});