272 lines · 8.6 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";import { vi } from "vite-plus/test"; vi.mock("expo-secure-store", () => ({  getItemAsync: vi.fn(),  setItemAsync: vi.fn(),})); vi.mock("react-native", () => ({  Platform: { OS: "ios" },})); vi.mock("../lib/runtime", async () => {  const Layer = await import("effect/Layer");  return {    runtime: { runPromise: vi.fn() },    runtimeContextLayer: Layer.empty,  };}); import type { Preferences } from "../persistence/mobile-preferences";import {  createMobilePreferencesState,  MobilePreferencesLoadError,  MobilePreferencesSaveError,  MobilePreferencesStore,} from "./preferences"; function deferred<A>() {  let resolve!: (value: A) => void;  const promise = new Promise<A>((resume) => {    resolve = resume;  });  return { promise, resolve } as const;} function makePreferencesState(  service: Omit<MobilePreferencesStore["Service"], "update"> &    Partial<Pick<MobilePreferencesStore["Service"], "update">>,) {  const completeService = MobilePreferencesStore.of({    ...service,    update:      service.update ??      ((transform) =>        service.load.pipe(          Effect.flatMap((current) => service.savePatch(transform(current))),          Effect.mapError((cause) =>            cause._tag === "MobilePreferencesSaveError"              ? cause              : new MobilePreferencesSaveError({ cause }),          ),        )),  });  return createMobilePreferencesState(    Atom.runtime(Layer.succeed(MobilePreferencesStore, completeService)),  );} describe("mobile preferences state", () => {  it.effect("shares one preference load across consumers", () =>    Effect.gen(function* () {      const load = vi.fn(() => Promise.resolve<Preferences>({ baseFontSize: 17 }));      const state = makePreferencesState({        load: Effect.promise(load),        savePatch: (patch) => Effect.succeed(patch),      });      const registry = AtomRegistry.make();      const unmountFirst = registry.mount(state.preferencesAtom);      const unmountSecond = registry.mount(state.preferencesAtom);       expect(        yield* AtomRegistry.getResult(registry, state.preferencesAtom, {          suspendOnWaiting: true,        }),      ).toEqual({ baseFontSize: 17 });      expect(load).toHaveBeenCalledTimes(1);       unmountSecond();      unmountFirst();      registry.dispose();    }),  );   it.effect("preserves an optimistic patch when the initial load finishes later", () =>    Effect.gen(function* () {      const pendingLoad = deferred<Preferences>();      const savePatch = vi.fn((patch: Partial<Preferences>) => Effect.succeed(patch));      const state = makePreferencesState({        load: Effect.promise(() => pendingLoad.promise),        savePatch,      });      const registry = AtomRegistry.make();      const unmountPreferences = registry.mount(state.preferencesAtom);      const unmountUpdate = registry.mount(state.updatePreferencesAtom);       registry.set(state.updatePreferencesAtom, {        collapsedProjectGroups: ["project:new"],      });      pendingLoad.resolve({        baseFontSize: 18,        collapsedProjectGroups: ["project:old"],      });       const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, {        suspendOnWaiting: true,      });      expect(preferences).toEqual({        baseFontSize: 18,        collapsedProjectGroups: ["project:new"],      });      expect(savePatch).toHaveBeenCalledWith({        collapsedProjectGroups: ["project:new"],      });      expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);       unmountUpdate();      unmountPreferences();      registry.dispose();    }),  );   it.effect("falls back to empty preferences when secure storage cannot be read", () =>    Effect.gen(function* () {      const state = makePreferencesState({        load: Effect.fail(          new MobilePreferencesLoadError({            cause: new Error("secure storage unavailable"),          }),        ),        savePatch: (patch) => Effect.succeed(patch),      });      const registry = AtomRegistry.make();      const unmount = registry.mount(state.preferencesAtom);       expect(        yield* AtomRegistry.getResult(registry, state.preferencesAtom, {          suspendOnWaiting: true,        }),      ).toEqual({});       unmount();      registry.dispose();    }),  );   it.effect("does not roll back a newer optimistic write with the same value", () =>    Effect.gen(function* () {      let saveCount = 0;      const state = makePreferencesState({        load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }),        savePatch: (patch) => {          saveCount += 1;          return saveCount === 1            ? Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") }))            : Effect.succeed(patch);        },      });      const registry = AtomRegistry.make();      const unmountPreferences = registry.mount(state.preferencesAtom);      const unmountUpdate = registry.mount(state.updatePreferencesAtom);       yield* AtomRegistry.getResult(registry, state.preferencesAtom, {        suspendOnWaiting: true,      });      registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });      registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 });       yield* Effect.promise(() =>        vi.waitFor(() => {          expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);          expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(            {              baseFontSize: 18,              codeFontSize: 15,            },          );        }),      );       unmountUpdate();      unmountPreferences();      registry.dispose();    }),  );   it.effect("rolls back an optimistic field when its save fails", () =>    Effect.gen(function* () {      const state = makePreferencesState({        load: Effect.succeed({ baseFontSize: 16 }),        savePatch: () =>          Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })),      });      const registry = AtomRegistry.make();      const unmountPreferences = registry.mount(state.preferencesAtom);      const unmountUpdate = registry.mount(state.updatePreferencesAtom);       yield* AtomRegistry.getResult(registry, state.preferencesAtom, {        suspendOnWaiting: true,      });      registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });       yield* Effect.promise(() =>        vi.waitFor(() => {          expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(            {              baseFontSize: 16,            },          );        }),      );       unmountUpdate();      unmountPreferences();      registry.dispose();    }),  );   it.effect("rolls back to the last confirmed value after a later save fails", () =>    Effect.gen(function* () {      let saveCount = 0;      const state = makePreferencesState({        load: Effect.succeed({ baseFontSize: 16 }),        savePatch: () => {          saveCount += 1;          return saveCount === 1            ? Effect.succeed({ baseFontSize: 14 })            : Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") }));        },      });      const registry = AtomRegistry.make();      const unmountPreferences = registry.mount(state.preferencesAtom);      const unmountUpdate = registry.mount(state.updatePreferencesAtom);       yield* AtomRegistry.getResult(registry, state.preferencesAtom, {        suspendOnWaiting: true,      });      registry.set(state.updatePreferencesAtom, { baseFontSize: 14 });      yield* Effect.promise(() =>        vi.waitFor(() => {          expect(saveCount).toBe(1);          expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false);          expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(            {              baseFontSize: 14,            },          );        }),      );       registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });      yield* Effect.promise(() =>        vi.waitFor(() => {          expect(saveCount).toBe(2);          expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(            {              baseFontSize: 14,            },          );        }),      );       unmountUpdate();      unmountPreferences();      registry.dispose();    }),  );});