1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
5import { vi } from "vite-plus/test";
6import { ProviderInstanceId } from "@t3tools/contracts";
7import { RegistryContext, useAtomSet } from "@effect/atom-react";
8import { createElement } from "react";
9import { renderToString } from "react-dom/server";
10
11vi.mock("expo-secure-store", () => ({
12 getItemAsync: vi.fn(),
13 setItemAsync: vi.fn(),
14}));
15
16vi.mock("react-native", () => ({
17 Platform: { OS: "ios" },
18}));
19
20vi.mock("../lib/runtime", async () => {
21 const Layer = await import("effect/Layer");
22 return {
23 runtime: { runPromise: vi.fn() },
24 layer: Layer.empty,
25 };
26});
27
28import { createMobilePreferencesState } from "./preferences";
29import * as MobilePreferences from "../persistence/mobile-preferences";
30
31function deferred<A>() {
32 let resolve!: (value: A) => void;
33 const promise = new Promise<A>((resume) => {
34 resolve = resume;
35 });
36 return { promise, resolve } as const;
37}
38
39function makePreferencesState(
40 service: Omit<MobilePreferences.MobilePreferencesStore["Service"], "update"> &
41 Partial<Pick<MobilePreferences.MobilePreferencesStore["Service"], "update">>,
42) {
43 const completeService = MobilePreferences.MobilePreferencesStore.of({
44 ...service,
45 update:
46 service.update ??
47 ((transform) =>
48 service.load.pipe(
49 Effect.flatMap((current) => service.savePatch(transform(current))),
50 Effect.mapError((cause) =>
51 cause._tag === "MobilePreferencesSaveError"
52 ? cause
53 : new MobilePreferences.MobilePreferencesSaveError({ cause }),
54 ),
55 )),
56 });
57 return createMobilePreferencesState(
58 Atom.runtime(Layer.succeed(MobilePreferences.MobilePreferencesStore, completeService)),
59 );
60}
61
62describe("mobile preferences state", () => {
63 it.effect("preserves an optimistic patch when the initial load finishes later", () =>
64 Effect.gen(function* () {
65 const pendingLoad = deferred<MobilePreferences.Preferences>();
66 const savePatch = vi.fn((patch: Partial<MobilePreferences.Preferences>) =>
67 Effect.succeed(patch),
68 );
69 const state = makePreferencesState({
70 load: Effect.promise(() => pendingLoad.promise),
71 savePatch,
72 });
73 const registry = AtomRegistry.make();
74 const unmountPreferences = registry.mount(state.preferencesAtom);
75 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
76
77 registry.set(state.updatePreferencesAtom, {
78 collapsedProjectGroups: ["project:new"],
79 });
80 pendingLoad.resolve({
81 baseFontSize: 18,
82 collapsedProjectGroups: ["project:old"],
83 });
84
85 const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
86 suspendOnWaiting: true,
87 });
88 expect(preferences).toEqual({
89 baseFontSize: 18,
90 collapsedProjectGroups: ["project:new"],
91 });
92 expect(savePatch).toHaveBeenCalledWith({
93 collapsedProjectGroups: ["project:new"],
94 });
95 expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);
96
97 unmountUpdate();
98 unmountPreferences();
99 registry.dispose();
100 }),
101 );
102
103 it.effect("keeps both favorites when the React setter sends updates before a render", () =>
104 Effect.gen(function* () {
105 let persisted: MobilePreferences.Preferences = { modelFavorites: [] };
106 const state = makePreferencesState({
107 load: Effect.succeed(persisted),
108 savePatch: (patch) =>
109 Effect.sync(() => {
110 persisted = { ...persisted, ...patch };
111 return persisted;
112 }),
113 update: (transform) =>
114 Effect.sync(() => {
115 persisted = { ...persisted, ...transform(persisted) };
116 return persisted;
117 }),
118 });
119 const registry = AtomRegistry.make();
120 const unmountPreferences = registry.mount(state.preferencesAtom);
121 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
122 yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });
123
124 function useSavePreferences() {
125 return useAtomSet(state.updatePreferencesAtom);
126 }
127 const setters: Array<ReturnType<typeof useSavePreferences>> = [];
128 function CaptureSetter() {
129 setters.push(useSavePreferences());
130 return null;
131 }
132 // Use the React setter; registry.set bypasses bare-function update semantics.
133 renderToString(
134 createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)),
135 );
136 const savePreferences = setters[0]!;
137 const provider = ProviderInstanceId.make("codex");
138 savePreferences({
139 transform: (current) => ({
140 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
141 }),
142 });
143 savePreferences({
144 transform: (current) => ({
145 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
146 }),
147 });
148 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
149 suspendOnWaiting: true,
150 });
151
152 expect(persisted.modelFavorites).toEqual([
153 { provider, model: "astra" },
154 { provider, model: "sol" },
155 ]);
156
157 savePreferences({
158 transform: (current) => ({
159 modelFavorites: (current.modelFavorites ?? []).filter(
160 (favorite) => favorite.model !== "astra",
161 ),
162 }),
163 });
164 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
165 suspendOnWaiting: true,
166 });
167 expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]);
168
169 unmountUpdate();
170 unmountPreferences();
171 registry.dispose();
172 }),
173 );
174
175 it.effect("falls back to empty preferences when secure storage cannot be read", () =>
176 Effect.gen(function* () {
177 const state = makePreferencesState({
178 load: Effect.fail(
179 new MobilePreferences.MobilePreferencesLoadError({
180 cause: new Error("secure storage unavailable"),
181 }),
182 ),
183 savePatch: (patch) => Effect.succeed(patch),
184 });
185 const registry = AtomRegistry.make();
186 const unmount = registry.mount(state.preferencesAtom);
187
188 expect(
189 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
190 suspendOnWaiting: true,
191 }),
192 ).toEqual({});
193
194 unmount();
195 registry.dispose();
196 }),
197 );
198});