| 1 | import { RegistryContext } from "@effect/atom-react"; |
| 2 | import { |
| 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | EnvironmentId, |
| 5 | ProviderInstanceId, |
| 6 | type ServerSettings, |
| 7 | type ServerSettingsPatch, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; |
| 10 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 11 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 12 | import { act } from "react"; |
| 13 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 15 | |
| 16 | const state = vi.hoisted(() => ({ |
| 17 | settings: new Map<EnvironmentId, Atom.Writable<ServerSettings | null>>(), |
| 18 | persist: vi.fn(), |
| 19 | })); |
| 20 | |
| 21 | vi.mock("~/state/server", () => ({ |
| 22 | serverEnvironment: { |
| 23 | settingsValueAtom: (id: EnvironmentId) => state.settings.get(id)!, |
| 24 | updateSettings: Symbol("updateSettings"), |
| 25 | }, |
| 26 | primaryServerSettingsAtom: undefined, |
| 27 | })); |
| 28 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); |
| 29 | vi.mock("~/localApi", () => ({ |
| 30 | ensureLocalApi: () => ({ persistence: { getClientSettings: async () => null } }), |
| 31 | })); |
| 32 | |
| 33 | import { useToggleEnvironmentModelFavorite } from "./useSettings"; |
| 34 | |
| 35 | const primaryId = EnvironmentId.make("primary"); |
| 36 | const remoteId = EnvironmentId.make("remote"); |
| 37 | const provider = ProviderInstanceId.make("codex_work"); |
| 38 | const model = "gpt-6.1-sol"; |
| 39 | const initialSettings = { |
| 40 | ...DEFAULT_SERVER_SETTINGS, |
| 41 | favorites: [], |
| 42 | providerModelPreferences: {}, |
| 43 | } satisfies ServerSettings; |
| 44 | let registry: AtomRegistry.AtomRegistry; |
| 45 | let renderer: ReactTestRenderer | undefined; |
| 46 | let durable: Map<EnvironmentId, ServerSettings>; |
| 47 | let writes: Array<{ |
| 48 | environmentId: EnvironmentId; |
| 49 | patch: ServerSettingsPatch; |
| 50 | finish: (success?: boolean) => void; |
| 51 | }>; |
| 52 | |
| 53 | function FavoriteEditor({ environmentId }: { environmentId: EnvironmentId }) { |
| 54 | const toggle = useToggleEnvironmentModelFavorite(environmentId); |
| 55 | return <button onClick={() => toggle(provider, model)}>Toggle favorite</button>; |
| 56 | } |
| 57 | |
| 58 | function editor(environmentId: EnvironmentId) { |
| 59 | return ( |
| 60 | <RegistryContext.Provider value={registry}> |
| 61 | <FavoriteEditor environmentId={environmentId} /> |
| 62 | </RegistryContext.Provider> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | async function mount(environmentId = primaryId) { |
| 67 | await act(() => { |
| 68 | renderer = create(editor(environmentId)); |
| 69 | }); |
| 70 | } |
| 71 | |
| 72 | function click(): Promise<void> { |
| 73 | return renderer!.root.findByType("button").props.onClick(); |
| 74 | } |
| 75 | |
| 76 | beforeEach(() => { |
| 77 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 78 | registry = AtomRegistry.make(); |
| 79 | state.settings.clear(); |
| 80 | durable = new Map(); |
| 81 | writes = []; |
| 82 | for (const id of [primaryId, remoteId]) { |
| 83 | state.settings.set(id, Atom.make<ServerSettings | null>(initialSettings)); |
| 84 | durable.set(id, initialSettings); |
| 85 | } |
| 86 | state.persist.mockReset().mockImplementation( |
| 87 | ({ |
| 88 | environmentId, |
| 89 | input, |
| 90 | }: { |
| 91 | environmentId: EnvironmentId; |
| 92 | input: { patch: ServerSettingsPatch }; |
| 93 | }) => |
| 94 | new Promise((resolve) => { |
| 95 | writes.push({ |
| 96 | environmentId, |
| 97 | patch: input.patch, |
| 98 | finish: (success = true) => { |
| 99 | if (!success) { |
| 100 | resolve(AsyncResult.failure(Cause.fail(new Error("save failed")))); |
| 101 | return; |
| 102 | } |
| 103 | const settings = applyServerSettingsPatch(durable.get(environmentId)!, input.patch); |
| 104 | durable.set(environmentId, settings); |
| 105 | resolve(AsyncResult.success(settings)); |
| 106 | }, |
| 107 | }); |
| 108 | }), |
| 109 | ); |
| 110 | }); |
| 111 | |
| 112 | afterEach(async () => { |
| 113 | await act(() => renderer?.unmount()); |
| 114 | renderer = undefined; |
| 115 | registry.dispose(); |
| 116 | vi.unstubAllGlobals(); |
| 117 | }); |
| 118 | |
| 119 | describe("environment favorite toggles", () => { |
| 120 | it.each([false, true])( |
| 121 | "toggles twice before any server echo when initially favorited=%s", |
| 122 | async (initiallyFavorited) => { |
| 123 | const favorites = initiallyFavorited ? [{ provider, model }] : []; |
| 124 | const settings = { ...initialSettings, favorites }; |
| 125 | registry.set(state.settings.get(primaryId)!, settings); |
| 126 | durable.set(primaryId, settings); |
| 127 | await mount(); |
| 128 | const first = click(); |
| 129 | const second = click(); |
| 130 | // Preparing the patch is asynchronous even when migration has already completed. |
| 131 | await act(async () => {}); |
| 132 | expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([ |
| 133 | !initiallyFavorited, |
| 134 | initiallyFavorited, |
| 135 | ]); |
| 136 | writes[0]!.finish(); |
| 137 | await first; |
| 138 | writes[1]!.finish(); |
| 139 | await second; |
| 140 | expect(durable.get(primaryId)!.favorites).toEqual(favorites); |
| 141 | }, |
| 142 | ); |
| 143 | |
| 144 | it("keeps the newest pending intent when an earlier write completes", async () => { |
| 145 | await mount(); |
| 146 | const first = click(); |
| 147 | const second = click(); |
| 148 | await act(async () => {}); |
| 149 | writes[0]!.finish(); |
| 150 | await first; |
| 151 | const third = click(); |
| 152 | await act(async () => {}); |
| 153 | expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([ |
| 154 | true, |
| 155 | false, |
| 156 | true, |
| 157 | ]); |
| 158 | writes[1]!.finish(); |
| 159 | await second; |
| 160 | writes[2]!.finish(); |
| 161 | await third; |
| 162 | expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); |
| 163 | }); |
| 164 | |
| 165 | it("clears failed intent so a retry still adds the favorite", async () => { |
| 166 | await mount(); |
| 167 | const failed = click(); |
| 168 | await act(async () => {}); |
| 169 | writes[0]!.finish(false); |
| 170 | await failed; |
| 171 | const retry = click(); |
| 172 | await act(async () => {}); |
| 173 | writes[1]!.finish(); |
| 174 | await retry; |
| 175 | expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); |
| 176 | }); |
| 177 | |
| 178 | it("toggles the acknowledged choice when the RPC reply precedes its server echo", async () => { |
| 179 | await mount(); |
| 180 | const first = click(); |
| 181 | await act(async () => {}); |
| 182 | writes[0]!.finish(); |
| 183 | await first; |
| 184 | expect(registry.get(state.settings.get(primaryId)!)!.favorites).toEqual([]); |
| 185 | const second = click(); |
| 186 | await act(async () => {}); |
| 187 | writes[1]!.finish(); |
| 188 | await second; |
| 189 | expect(durable.get(primaryId)!.favorites).toEqual([]); |
| 190 | }); |
| 191 | |
| 192 | it("uses a later remote change instead of a cached acknowledged choice", async () => { |
| 193 | await mount(); |
| 194 | const first = click(); |
| 195 | await act(async () => {}); |
| 196 | writes[0]!.finish(); |
| 197 | await first; |
| 198 | // Another client removed the favorite after this client's add was acknowledged. |
| 199 | const remoteSettings = { ...initialSettings, favorites: [] }; |
| 200 | durable.set(primaryId, remoteSettings); |
| 201 | await act(() => registry.set(state.settings.get(primaryId)!, remoteSettings)); |
| 202 | const second = click(); |
| 203 | await act(async () => {}); |
| 204 | writes[1]!.finish(); |
| 205 | await second; |
| 206 | expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); |
| 207 | }); |
| 208 | |
| 209 | it("preserves an acknowledged add when a later remove fails before its echo", async () => { |
| 210 | await mount(); |
| 211 | const first = click(); |
| 212 | await act(async () => {}); |
| 213 | writes[0]!.finish(); |
| 214 | await first; |
| 215 | const failed = click(); |
| 216 | await act(async () => {}); |
| 217 | writes[1]!.finish(false); |
| 218 | await failed; |
| 219 | const retry = click(); |
| 220 | await act(async () => {}); |
| 221 | writes[2]!.finish(); |
| 222 | await retry; |
| 223 | expect(durable.get(primaryId)!.favorites).toEqual([]); |
| 224 | }); |
| 225 | |
| 226 | it("does not reuse another environment's pending intent for the same model", async () => { |
| 227 | await mount(); |
| 228 | const primary = click(); |
| 229 | await act(() => renderer!.update(editor(remoteId))); |
| 230 | const remote = click(); |
| 231 | await act(async () => {}); |
| 232 | writes[0]!.finish(); |
| 233 | await primary; |
| 234 | writes[1]!.finish(); |
| 235 | await remote; |
| 236 | expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); |
| 237 | expect(durable.get(remoteId)!.favorites).toEqual([{ provider, model }]); |
| 238 | }); |
| 239 | |
| 240 | it("reads the latest server state even when the click callback predates its echo", async () => { |
| 241 | await mount(); |
| 242 | const previousClick = renderer!.root.findByType("button").props.onClick as () => Promise<void>; |
| 243 | const updated = { ...initialSettings, favorites: [{ provider, model }] }; |
| 244 | durable.set(primaryId, updated); |
| 245 | await act(() => registry.set(state.settings.get(primaryId)!, updated)); |
| 246 | const clicked = previousClick(); |
| 247 | await act(async () => {}); |
| 248 | writes[0]!.finish(); |
| 249 | await clicked; |
| 250 | expect(durable.get(primaryId)!.favorites).toEqual([]); |
| 251 | }); |
| 252 | }); |