Skip to content
Source
Sign inSign up

threadPromptLink.test.ts

144 lines5.4 KB

1import type { NavigationState, PartialState } from "@react-navigation/native";
2import * as Option from "effect/Option";
3import { AsyncResult, Atom } from "effect/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";
5​
6const fixture = vi.hoisted(() => ({
7 applied: [] as { draftKey: string; text: string }[],
8 root: undefined as NavigationState | undefined,
9 detailOpens: 0,
10}));
11​
12vi.mock("../../components/ui/Dialog", () => ({ alert: () => {} }));
13vi.mock("./prompt-link-actions", () => ({
14 applyLinkedPromptToThreadDraft: async (draftKey: string, prompt: { text: string }) => {
15 fixture.applied.push({ draftKey, text: prompt.text });
16 },
17}));
18vi.mock("../../lib/rootNavigation", () => ({
19 rootNavigationRef: { getRootState: () => fixture.root },
20}));
21vi.mock("../../state/atom-registry", async () => {
22 const { AtomRegistry } = await import("effect/reactivity");
23 return { appAtomRegistry: AtomRegistry.make() };
24});
25vi.mock("../../state/threads", async () => {
26 const { AsyncResult, Atom } = await import("effect/reactivity");
27 const Option = await import("effect/Option");
28 const shell = Atom.make<object | null>(null);
29 const detailSource = Atom.make(AsyncResult.success({ data: Option.none() }));
30 // Computing the real detail atom opens the thread's server stream.
31 const detail = Atom.make((get) => {
32 fixture.detailOpens += 1;
33 return get(detailSource);
34 });
35 return {
36 environmentThreadShells: { threadShellAtom: () => shell },
37 environmentThreads: { stateAtom: () => detail },
38 fixtureShell: shell,
39 fixtureDetail: detail,
40 fixtureDetailSource: detailSource,
41 };
42});
43​
44const { appAtomRegistry } = await import("../../state/atom-registry");
45const { fixtureDetail, fixtureDetailSource, fixtureShell } =
46 (await import("../../state/threads")) as unknown as {
47 fixtureShell: Atom.Writable<object | null>;
48 fixtureDetail: Atom.Atom<unknown>;
49 fixtureDetailSource: Atom.Writable<AsyncResult.AsyncResult<{ data: Option.Option<object> }>>;
50 };
51const { resolvePendingThreadPromptLink, takeThreadPromptLink } = await import("./threadPromptLink");
52​
53function setup() {
54 fixture.applied.length = 0;
55 fixture.root = undefined;
56 appAtomRegistry.reset();
57 appAtomRegistry.set(fixtureShell, null);
58 appAtomRegistry.set(fixtureDetailSource, AsyncResult.success({ data: Option.none() }));
59 fixture.detailOpens = 0;
61​
62const loadedDetail = AsyncResult.success({ data: Option.some({}) });
63​
64const threadParams = { environmentId: "env-1", threadId: "thread-1" };
65​
66function linkedState(prompt = "Fix the bug"): PartialState<NavigationState> {
67 return { routes: [{ name: "Thread", params: { ...threadParams, prompt } }] };
69​
70function stack(routes: NavigationState["routes"]): NavigationState {
71 return {
72 key: "root",
73 index: routes.length - 1,
74 routeNames: routes.map((route) => route.name),
75 routes,
76 stale: false,
77 type: "stack",
78 };
80​
81const threadRoute = stack([{ key: "thread", name: "Thread", params: threadParams }]);
82​
83describe("takeThreadPromptLink", () => {
84 it("writes no draft for a thread that never shows", () => {
85 setup();
86 takeThreadPromptLink(linkedState());
87 expect(fixture.applied).toEqual([]);
88​
89 resolvePendingThreadPromptLink(threadRoute);
90 // An unknown thread's screen replaces itself with a new-thread draft.
91 resolvePendingThreadPromptLink(
92 stack([{ key: "draft", name: "Thread", params: { draft: { draftId: "d" } } }]),
93 );
94 appAtomRegistry.set(fixtureShell, { id: "thread-1" });
95 expect(fixture.applied).toEqual([]);
96 });
97​
98 it("fills the draft once the linked thread shows on its screen", () => {
99 setup();
100 takeThreadPromptLink(linkedState());
101 fixture.root = threadRoute;
102 resolvePendingThreadPromptLink(threadRoute);
103 expect(fixture.applied).toEqual([]);
104​
105 appAtomRegistry.set(fixtureShell, { id: "thread-1" });
106 expect(fixture.applied).toEqual([{ draftKey: expect.any(String), text: "Fix the bug" }]);
107 appAtomRegistry.set(fixtureShell, { id: "thread-1", title: "renamed" });
108 expect(fixture.applied).toHaveLength(1);
109 });
110​
111 it("fills the draft of an archived thread its screen shows from detail", () => {
112 setup();
113 takeThreadPromptLink(linkedState());
114 fixture.root = threadRoute;
115 // Archived threads have no shell; the open screen subscribes to their detail instead.
116 using _screen = { [Symbol.dispose]: appAtomRegistry.mount(fixtureDetail) };
117 resolvePendingThreadPromptLink(threadRoute);
118 expect(fixture.applied).toEqual([]);
119​
120 appAtomRegistry.set(fixtureDetailSource, loadedDetail);
121 expect(fixture.applied).toEqual([{ draftKey: expect.any(String), text: "Fix the bug" }]);
122 });
123​
124 it("opens no detail for a thread no screen has loaded", () => {
125 setup();
126 takeThreadPromptLink(linkedState());
127 fixture.root = threadRoute;
128 resolvePendingThreadPromptLink(threadRoute);
129 appAtomRegistry.set(fixtureDetailSource, loadedDetail);
130 expect(fixture.detailOpens).toBe(0);
131 expect(fixture.applied).toEqual([]);
132 resolvePendingThreadPromptLink(stack([{ key: "home", name: "Home" }]));
133 });
134​
135 it("drops an earlier link to the thread when a newer one is invalid", () => {
136 setup();
137 takeThreadPromptLink(linkedState());
138 takeThreadPromptLink(linkedState("\u200b"));
139 fixture.root = threadRoute;
140 resolvePendingThreadPromptLink(threadRoute);
141 appAtomRegistry.set(fixtureShell, { id: "thread-1" });
142 expect(fixture.applied).toEqual([]);
143 });
144});