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() {
let resolve!: (value: A) => void;
const promise = new Promise((resume) => {
resolve = resume;
});
return { promise, resolve } as const;
}
function makePreferencesState(
service: Omit &
Partial>,
) {
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({ 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();
const savePatch = vi.fn((patch: Partial) => 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();
}),
);
});