Files
Termix/src/ui/tests/onboarding/onboarding-flow.test.ts
T

214 lines
5.7 KiB
TypeScript
Raw Normal View History

2026-10-10 14:58:27 -05:00
import { describe, expect, it } from "vitest";
import {
baselineSeen,
computeFlow,
needsFullFlow,
seenFor,
} from "@/onboarding/onboarding-flow";
import {
mergeStepDefs,
sectionPosition,
type OnboardingCtx,
type OnboardingStepDef,
} from "@/onboarding/onboarding-registry";
import { defaultOnboardingState } from "@/types/ui-preferences";
const Noop = () => null;
function step(
id: string,
position: number,
extra: Partial<OnboardingStepDef> = {},
): OnboardingStepDef {
return {
id,
version: 1,
titleKey: id,
Component: Noop,
audience: "all",
position,
...extra,
};
}
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
return {
mode: "partial",
isAdmin: false,
isDesktop: false,
has: () => false,
pluginSetupPending: false,
pluginsManagedElsewhere: false,
...overrides,
};
}
const steps = [
step("welcome", 0),
step("appearance", 40, { version: 2 }),
step("rd:guacd", sectionPosition("setup"), {
pluginId: "rd",
audience: "admin",
}),
step("ai:assistant", sectionPosition("explore"), {
pluginId: "ai",
permission: "ai.use",
}),
step("done", 100),
];
const ids = (list: OnboardingStepDef[]) => list.map((s) => s.id);
describe("computeFlow", () => {
it("shows every relevant step on a full run", () => {
expect(ids(computeFlow(steps, {}, ctx({ mode: "full" })))).toEqual([
"welcome",
"appearance",
"done",
]);
});
it("filters admin and permission steps", () => {
const flow = computeFlow(
steps,
{},
ctx({ mode: "rerun", isAdmin: true, has: (p) => p === "ai.use" }),
);
expect(ids(flow)).toEqual([
"welcome",
"appearance",
"rd:guacd",
"ai:assistant",
"done",
]);
});
it("shows only unseen or bumped steps on a partial run", () => {
const flow = computeFlow(
steps,
{ welcome: 1, appearance: 1, "rd:guacd": 1, done: 1 },
ctx({ isAdmin: true, has: () => true }),
);
expect(ids(flow)).toEqual(["appearance", "ai:assistant"]);
});
it("is empty when everything is seen", () => {
expect(
computeFlow(
steps,
{ welcome: 1, appearance: 2, "rd:guacd": 1, done: 1 },
ctx({ isAdmin: true }),
),
).toEqual([]);
});
it("includes a seen step that forces itself pending", () => {
const picker = step("plugins", 10, {
forcePending: (c) => c.pluginSetupPending,
});
expect(
ids(
computeFlow(
[picker],
{ plugins: 1 },
ctx({ pluginSetupPending: true }),
),
),
).toEqual(["plugins"]);
expect(computeFlow([picker], { plugins: 1 }, ctx())).toEqual([]);
});
it("drops a step whose relevance check throws", () => {
const broken = step("x:y", 50, {
isRelevant: () => {
throw new Error("boom");
},
});
expect(computeFlow([broken], {}, ctx())).toEqual([]);
});
});
describe("seen bookkeeping", () => {
it("records every step in a run at its version", () => {
expect(seenFor(steps.slice(0, 2))).toEqual({ welcome: 1, appearance: 2 });
});
it("baselines only plugin steps", () => {
expect(baselineSeen(steps)).toEqual({ "rd:guacd": 1, "ai:assistant": 1 });
});
it("needs the full run only for someone who never finished one", () => {
expect(needsFullFlow(defaultOnboardingState())).toBe(true);
expect(
needsFullFlow({ ...defaultOnboardingState(), seen: { welcome: 1 } }),
).toBe(false);
expect(
needsFullFlow({ ...defaultOnboardingState(), completedAt: "x" }),
).toBe(false);
});
});
describe("mergeStepDefs", () => {
it("orders by position and lets a registered step win over a legacy one", () => {
const legacy = step("ai:assistant", 80, { titleKey: "old" });
const registered = step("ai:assistant", 50, { titleKey: "new" });
const merged = mergeStepDefs(
[step("welcome", 0), step("done", 100)],
[registered],
[legacy, step("x:legacy", 80)],
);
expect(merged.map((s) => [s.id, s.titleKey])).toEqual([
["welcome", "welcome"],
["ai:assistant", "new"],
["x:legacy", "x:legacy"],
["done", "done"],
]);
});
it("places plugin sections between the core steps", () => {
expect(sectionPosition("setup")).toBeGreaterThan(40);
expect(sectionPosition("setup")).toBeLessThan(60);
expect(sectionPosition("explore", 1000)).toBeLessThan(90);
expect(sectionPosition("security")).toBeLessThan(100);
});
});
2026-10-20 16:03:29 -05:00
describe("start section and explicit position", () => {
it("puts the start section before the setup section", () => {
expect(sectionPosition("start")).toBe(25);
expect(sectionPosition("start")).toBeLessThan(sectionPosition("setup"));
});
it("uses an explicit position over the section and clamps it", () => {
expect(sectionPosition("explore", 0, 30)).toBe(30);
expect(sectionPosition("explore", 0, -5)).toBe(1);
expect(sectionPosition("explore", 0, 500)).toBe(99);
});
});
describe("replaced steps", () => {
const replacing = step("layout:preset", 30, {
pluginId: "layout",
replaces: ["preset"],
});
it("is not pending for someone who saw the step it replaces", () => {
const flow = computeFlow([replacing], { preset: 1 }, ctx());
expect(flow).toEqual([]);
});
it("is pending for someone who saw neither", () => {
expect(ids(computeFlow([replacing], {}, ctx()))).toEqual(["layout:preset"]);
});
it("is pending again when its own version goes up", () => {
const bumped = { ...replacing, version: 2 };
expect(ids(computeFlow([bumped], { preset: 1 }, ctx()))).toEqual([
"layout:preset",
]);
expect(
computeFlow([bumped], { preset: 1, "layout:preset": 2 }, ctx()),
).toEqual([]);
});
});