Files
crowd-funder-for-time-pwa/src/test/onboarding.test.ts
T

316 lines
10 KiB
TypeScript

import {
boundingBoxAround,
isNudgeOn,
locBoxToBoundingBox,
offersAlerts,
onboardingSettingsUpdate,
type OnboardingState,
parseOnboardingState,
roundCoordinate,
serializeOnboardingState,
shouldAnimateBanner,
shouldOpenWelcome,
similarityLabel,
withBannerShown,
withDeclined,
withDone,
withInterests,
withMatchesShown,
withoutInterests,
withWelcomeTour,
} from "@/libs/onboarding";
import {
fetchSimilar,
SIMILAR_BUSY_MESSAGE,
SIMILAR_NETWORK_MESSAGE,
SIMILAR_RATE_LIMITED_MESSAGE,
similarUrl,
} from "@/libs/partnerServer";
const NOW = new Date("2026-10-25T16:02:11Z");
const POINT = { lat: 40.76543, lon: -111.891234 };
describe("onboarding state", () => {
it("starts at the prompt when nothing is stored or it is unreadable", () => {
expect(parseOnboardingState(undefined)).toEqual({ stage: "interests" });
expect(parseOnboardingState("")).toEqual({ stage: "interests" });
expect(parseOnboardingState("{not json")).toEqual({ stage: "interests" });
expect(parseOnboardingState('{"stage":"bogus"}').stage).toBe("interests");
});
it("round-trips through the settings column", () => {
const state: OnboardingState = {
stage: "save",
interests: "gardening",
lat: 40.77,
lon: -111.89,
bannerShownAt: null,
};
expect(parseOnboardingState(serializeOnboardingState(state))).toEqual(
state,
);
});
it("walks interests → matches → save → done", () => {
let state = parseOnboardingState(undefined);
state = withInterests(state, " vegetable gardening ", POINT);
expect(state).toMatchObject({
stage: "matches",
interests: "vegetable gardening",
lat: 40.77,
lon: -111.89,
});
state = withMatchesShown(state, NOW);
expect(state.stage).toBe("save");
expect(state.matchesShownAt).toBe(NOW.toISOString());
state = withDone(state);
expect(state.stage).toBe("done");
});
it("only moves to save from matches", () => {
const interests: OnboardingState = { stage: "interests" };
expect(withMatchesShown(interests, NOW)).toBe(interests);
const done: OnboardingState = { stage: "done" };
expect(withMatchesShown(done, NOW)).toBe(done);
});
it("restarts from done but leaves a nudge already on", () => {
expect(withInterests({ stage: "done" }, "x", null).stage).toBe("matches");
expect(withInterests({ stage: "save" }, "x", null).stage).toBe("save");
expect(withInterests({ stage: "matches" }, "x", null).stage).toBe(
"matches",
);
});
it("drops the point when the search runs without one", () => {
const state = withInterests(
{ stage: "done", interests: "a", lat: 1, lon: 2 },
"b",
null,
);
expect(state.lat).toBeUndefined();
expect(state.lon).toBeUndefined();
});
it("sets finishedOnboarding only once the stage is done", () => {
expect(onboardingSettingsUpdate({ stage: "save" })).toEqual({
onboardingState: '{"stage":"save"}',
});
expect(onboardingSettingsUpdate({ stage: "done" }).finishedOnboarding).toBe(
true,
);
});
});
describe("kept interests", () => {
const kept = withInterests({ stage: "interests" }, "teaching", POINT);
it("stay when the stage reaches done", () => {
expect(withDone(withMatchesShown(kept, NOW))).toMatchObject({
interests: "teaching",
lat: 40.77,
});
});
it("are kept from text typed before 'Not now'", () => {
const state = withDeclined({ stage: "interests" }, NOW, {
interests: "bikes",
point: POINT,
});
expect(state).toMatchObject({
stage: "done",
declinedAt: NOW.toISOString(),
interests: "bikes",
lat: 40.77,
lon: -111.89,
});
});
it("are untouched by 'Not now' with nothing typed", () => {
const state = withDeclined(kept, NOW, { interests: " ", point: null });
expect(state).toMatchObject({ stage: "done", interests: "teaching" });
});
it("stay while the person is unregistered, with no nudge", () => {
const state = withMatchesShown(kept, NOW);
expect(isNudgeOn(state, false)).toBe(false);
expect(shouldAnimateBanner(state, false)).toBe(false);
expect(state.interests).toBe("teaching");
// registration later turns it on, with the banner still unspent
expect(isNudgeOn(state, true)).toBe(true);
expect(shouldAnimateBanner(state, true)).toBe(true);
});
it("are cleared by a profile save or 'Forget'", () => {
const state = withoutInterests(withMatchesShown(kept, NOW));
expect(state.interests).toBeUndefined();
expect(state.lat).toBeUndefined();
expect(state.lon).toBeUndefined();
expect(state.stage).toBe("save");
});
});
describe("nudge and welcome", () => {
it("animates the banner once", () => {
const state = withBannerShown({ stage: "save" }, NOW);
expect(shouldAnimateBanner(state, true)).toBe(false);
expect(isNudgeOn(state, true)).toBe(true);
expect(withBannerShown(state, new Date(0)).bannerShownAt).toBe(
NOW.toISOString(),
);
});
it("opens the welcome pages only on a tour from Help", () => {
expect(shouldOpenWelcome({})).toBe(false);
expect(
shouldOpenWelcome({
onboardingState: serializeOnboardingState({ stage: "interests" }),
}),
).toBe(false);
const tour = serializeOnboardingState(
withWelcomeTour({ stage: "done" }, true),
);
expect(shouldOpenWelcome({ onboardingState: tour })).toBe(true);
const ended = serializeOnboardingState(
withWelcomeTour({ stage: "done", welcomeTour: true }, false),
);
expect(shouldOpenWelcome({ onboardingState: ended })).toBe(false);
});
it("offers alerts only on the native phone apps", () => {
expect(offersAlerts({ isNativeApp: true, isMobile: true })).toBe(true);
// Electron
expect(offersAlerts({ isNativeApp: true, isMobile: false })).toBe(false);
// a phone's browser
expect(offersAlerts({ isNativeApp: false, isMobile: true })).toBe(false);
});
});
describe("location helpers", () => {
it("rounds to two decimals", () => {
expect(roundCoordinate(40.76543)).toBe(40.77);
expect(roundCoordinate(-111.891234)).toBe(-111.89);
expect(roundCoordinate(0.004)).toBe(0);
});
it("builds a box of about 50 km, widened in longitude by latitude", () => {
const equator = boundingBoxAround(0, 0);
expect(equator.maxLocLat - equator.minLocLat).toBeCloseTo(0.9, 2);
expect(equator.maxLocLon - equator.minLocLon).toBeCloseTo(0.9, 2);
const north = boundingBoxAround(60, 10);
expect(north.maxLocLat - 60).toBeCloseTo(0.45, 2);
// cos(60°) = 0.5, so twice as wide in degrees
expect(north.maxLocLon - 10).toBeCloseTo(0.9, 2);
});
it("stays inside the valid ranges near the poles and date line", () => {
const box = boundingBoxAround(89.9, 179.9);
expect(box.maxLocLat).toBeLessThanOrEqual(90);
expect(box.maxLocLon).toBeLessThanOrEqual(180);
expect(box.minLocLon).toBeGreaterThanOrEqual(-180);
});
it("converts to a search area", () => {
expect(
locBoxToBoundingBox({
minLocLat: 1,
maxLocLat: 2,
minLocLon: 3,
maxLocLon: 4,
}),
).toEqual({ minLat: 1, maxLat: 2, westLong: 3, eastLong: 4 });
});
it("labels similarity by the server's cuts", () => {
expect(similarityLabel(0.9)).toBe("strong");
expect(similarityLabel(0.85)).toBe("strong");
expect(similarityLabel(0.82)).toBe("good");
expect(similarityLabel(0.79)).toBe("some");
});
});
describe("fetchSimilar", () => {
const server = "https://partner.example";
function respond(status: number, body: unknown) {
return jest.fn().mockResolvedValue({
status,
json: () =>
body === undefined
? Promise.reject(new Error("no body"))
: Promise.resolve(body),
} as unknown as Response);
}
it("sends the text, both targets, and the box", () => {
const url = new URL(
similarUrl(server, {
text: "gardening & kids",
box: { minLocLat: 1, minLocLon: 2, maxLocLat: 3, maxLocLon: 4 },
}),
);
expect(url.pathname).toBe("/api/partner/similar");
expect(url.searchParams.get("from")).toBe("text:gardening & kids");
expect(url.searchParams.get("to")).toBe("both");
expect(url.searchParams.get("limit")).toBe("50");
expect(url.searchParams.get("minLocLon")).toBe("2");
expect(url.searchParams.get("maxLocLat")).toBe("3");
});
it("leaves the box off when there is no point", () => {
const url = new URL(similarUrl(server, { text: "x" }));
expect(url.searchParams.has("minLocLat")).toBe(false);
});
it("returns the matches on 200", async () => {
const data = [
{ subjectType: "plan", subjectId: "p", similarity: 0.9, record: {} },
];
const result = await fetchSimilar(
server,
{},
{ text: "x" },
respond(200, { data }),
);
expect(result).toEqual({ kind: "ok", matches: data });
});
it("maps 429, 503, and an unreachable server", async () => {
expect(
await fetchSimilar(server, {}, { text: "x" }, respond(429, {})),
).toEqual({ kind: "rateLimited", message: SIMILAR_RATE_LIMITED_MESSAGE });
expect(
await fetchSimilar(server, {}, { text: "x" }, respond(503, undefined)),
).toEqual({ kind: "busy", message: SIMILAR_BUSY_MESSAGE });
const offline = jest.fn().mockRejectedValue(new TypeError("offline"));
expect(await fetchSimilar(server, {}, { text: "x" }, offline)).toEqual({
kind: "network",
message: SIMILAR_NETWORK_MESSAGE,
});
});
it("passes the server's message on other errors", async () => {
const result = await fetchSimilar(
server,
{},
{ text: "x" },
respond(400, { error: "The query text is empty." }),
);
expect(result).toEqual({
kind: "error",
status: 400,
message: "The query text is empty.",
});
});
it("rejects a 200 without a data list", async () => {
const result = await fetchSimilar(
server,
{},
{ text: "x" },
respond(200, { nope: true }),
);
expect(result.kind).toBe("error");
});
});