1import { describe, expect, it } from "vite-plus/test";
2import type { NavigationState } from "@react-navigation/native";
3
4import type { RecentThreadShortcut } from "../../persistence/imperative";
5import {
6 activeThreadRef,
7 buildShortcutActions,
8 MAX_RECENT_THREAD_SHORTCUTS,
9 NEW_TASK_SHORTCUT_ID,
10 shortcutHref,
11 withRecentThreadShortcut,
12} from "./appShortcuts";
13
14function navState(route: { name: string; params?: unknown }): NavigationState {
15 return { index: 0, routes: [route] } as unknown as NavigationState;
16}
17
18function thread(suffix: string, title = `Thread ${suffix}`): RecentThreadShortcut {
19 return { environmentId: `env-${suffix}`, threadId: `thread-${suffix}`, title };
20}
21
22describe("withRecentThreadShortcut", () => {
23 it("prepends a newly opened thread", () => {
24 const next = withRecentThreadShortcut([thread("a")], thread("b"));
25 expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]);
26 });
27
28 it("moves a reopened thread to the front without duplicating it", () => {
29 const next = withRecentThreadShortcut([thread("a"), thread("b")], thread("b"));
30 expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]);
31 });
32
33 it("caps the list at the shortcut budget", () => {
34 const current = [thread("a"), thread("b"), thread("c")];
35 const next = withRecentThreadShortcut(current, thread("d"));
36 expect(next).toHaveLength(MAX_RECENT_THREAD_SHORTCUTS);
37 expect(next[0]?.threadId).toBe("thread-d");
38 expect(next.map((entry) => entry.threadId)).not.toContain("thread-c");
39 });
40
41 it("returns the same array when the thread already leads with the same title", () => {
42 const current = [thread("a"), thread("b")];
43 expect(withRecentThreadShortcut(current, thread("a"))).toBe(current);
44 });
45
46 it("keeps the known title when a reopen records an empty one", () => {
47 const current = [thread("a", "Fix the build")];
48 const next = withRecentThreadShortcut(current, thread("a", ""));
49 expect(next).toBe(current);
50 });
51
52 it("updates the title once the shell provides one", () => {
53 const current = [thread("a", "")];
54 const next = withRecentThreadShortcut(current, thread("a", "Fix the build"));
55 expect(next[0]?.title).toBe("Fix the build");
56 expect(next).toHaveLength(1);
57 });
58});
59
60describe("buildShortcutActions", () => {
61 it("leads with the static new-task action", () => {
62 const actions = buildShortcutActions([thread("a")]);
63 expect(actions[0]?.id).toBe(NEW_TASK_SHORTCUT_ID);
64 expect(actions[0]?.params?.href).toBe("/new");
65 expect(actions).toHaveLength(2);
66 });
67
68 it("deep-links threads with encoded route params", () => {
69 const actions = buildShortcutActions([
70 { environmentId: "env 1", threadId: "thread/2", title: "Spaced out" },
71 ]);
72 expect(actions[1]?.params?.href).toBe("/threads/env%201/thread%2F2");
73 expect(actions[1]?.title).toBe("Spaced out");
74 });
75
76 it("falls back to a generic label for missing titles", () => {
77 const actions = buildShortcutActions([thread("a", " ")]);
78 expect(actions[1]?.title).toBe("Thread");
79 });
80});
81
82describe("shortcutHref", () => {
83 it("accepts exactly the destinations shortcuts can produce", () => {
84 expect(shortcutHref({ id: "x", title: "x", params: { href: "/new" } })).toBe("/new");
85 expect(shortcutHref({ id: "x", title: "x", params: { href: "/threads/env-1/thread-2" } })).toBe(
86 "/threads/env-1/thread-2",
87 );
88 expect(
89 shortcutHref({ id: "x", title: "x", params: { href: "/threads/env%201/thread%2F2" } }),
90 ).toBe("/threads/env%201/thread%2F2");
91 });
92
93 it("rejects everything else", () => {
94 for (const href of [
95 "https://evil.example",
96 "//evil.example",
97 "/settings",
98 "/threads/only-one-segment",
99 "/threads/a/b/c",
100 "/threads//x",
101 "/threads/a/b?x=1",
102 "/threads/a/b#frag",
103 "/new/extra",
104 ]) {
105 expect(shortcutHref({ id: "x", title: "x", params: { href } })).toBe(null);
106 }
107 expect(shortcutHref({ id: "x", title: "x", params: { href: 3 } })).toBe(null);
108 expect(shortcutHref({ id: "x", title: "x" })).toBe(null);
109 });
110});
111
112describe("activeThreadRef", () => {
113 it("resolves the active Thread route's params", () => {
114 const ref = activeThreadRef(
115 navState({ name: "Thread", params: { environmentId: "env-1", threadId: "thread-2" } }),
116 );
117 expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" });
118 });
119
120 it("takes the first value of array params", () => {
121 const ref = activeThreadRef(
122 navState({ name: "Thread", params: { environmentId: ["env-1"], threadId: ["thread-2"] } }),
123 );
124 expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" });
125 });
126
127 it("returns null for non-thread routes", () => {
128 expect(activeThreadRef(navState({ name: "Home" }))).toBe(null);
129 });
130
131 it("returns null instead of throwing for malformed params", () => {
132 const malformed: unknown[] = [
133 undefined,
134 {},
135 { environmentId: "env-1" },
136 { environmentId: "", threadId: "thread-2" },
137 { environmentId: " ", threadId: "thread-2" },
138 { environmentId: "env-1", threadId: " " },
139 { environmentId: 42, threadId: "thread-2" },
140 { environmentId: { nested: true }, threadId: "thread-2" },
141 { environmentId: [], threadId: [] },
142 ];
143 for (const params of malformed) {
144 expect(activeThreadRef(navState({ name: "Thread", params }))).toBe(null);
145 }
146 });
147});
148
149describe("launcher shortcut ids", () => {
150 it("cannot collide across different env/thread pairs", () => {
151 const a = buildShortcutActions([{ environmentId: "a-b", threadId: "c", title: "x" }]);
152 const b = buildShortcutActions([{ environmentId: "a", threadId: "b-c", title: "x" }]);
153 expect(a[1]?.id).not.toBe(b[1]?.id);
154 });
155});