316 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|