1
0
Fork 0
polaris-task-force/tests/int/tours.int.spec.ts
Z8MB1E 8b79c1379d feat(tours): add permission-aware guided tours with replay and device opt-out
Pages declare brief tours in code, keyed to stable data-tour anchors. The layout resolves each visitor's access signals (intelligence and logistics qualifications, admin-panel access, feature flags) and step filtering hides what a user cannot reach, mirroring the sidebar. A tour auto-starts the first time a browser visits its page; dismissals are remembered per browser in localStorage. The header gains a replay control for on-demand runs on toured routes, the tour card gains Skip all tours on this device to opt a browser out of auto-starts, and the Account page holds the re-enable toggle. The card and spotlight get a distinct overlay treatment (glow, accent strip, outlined target).
2026-09-30 18:43:21 -04:00

192 lines
6.1 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_GATES,
visibleTourSteps,
type TourGateContext,
type TourStep,
} from "@/lib/tours/types";
import { TOURS, getTourForPath } from "@/lib/tours/registry";
import {
isTourDismissed,
isToursOptedOut,
readTourDismissals,
setToursOptedOut,
storeTourDismissal,
writeTourDismissals,
} from "@/lib/tours/dismissals";
const gates = (overrides: Partial<TourGateContext>): TourGateContext => ({
...DEFAULT_GATES,
...overrides,
});
describe("tour step filtering", () => {
const steps: TourStep[] = [
{ id: "open", selector: '[data-tour="page-header"]', title: "Open", body: "b" },
{
id: "logistics-only",
selector: '[data-tour="logistics-only"]',
title: "Logistics",
body: "b",
showWhen: (g) => g.hasLogistics,
},
{
id: "intel-only",
selector: '[data-tour="intel-only"]',
title: "Intel",
body: "b",
showWhen: (g) => g.hasIntelligence,
},
{
id: "stats-flag",
selector: '[data-tour="stats"]',
title: "Stats",
body: "b",
showWhen: (g) => g.statisticsEnabled,
},
];
it("a fully privileged visitor sees every step", () => {
expect(visibleTourSteps(steps, DEFAULT_GATES)).toHaveLength(4);
});
it("a visitor without logistics qualification never sees logistics steps", () => {
const visible = visibleTourSteps(steps, gates({ hasLogistics: false }));
expect(visible.map((s) => s.id)).toEqual(["open", "intel-only", "stats-flag"]);
});
it("a visitor without intelligence qualification never sees intel steps", () => {
const visible = visibleTourSteps(steps, gates({ hasIntelligence: false }));
expect(visible.map((s) => s.id)).toEqual(["open", "logistics-only", "stats-flag"]);
});
it("feature-flag gates hide steps when the feature is disabled", () => {
const visible = visibleTourSteps(steps, gates({ statisticsEnabled: false }));
expect(visible.map((s) => s.id)).toEqual(["open", "logistics-only", "intel-only"]);
});
it("a visitor with nothing unlocked sees only ungated steps", () => {
const visible = visibleTourSteps(
steps,
gates({
hasLogistics: false,
hasIntelligence: false,
hasAdminPanel: false,
statisticsEnabled: false,
helpdeskEnabled: false,
}),
);
expect(visible.map((s) => s.id)).toEqual(["open"]);
});
});
describe("tour registry", () => {
it("covers the major sidebar routes", () => {
const paths = TOURS.map((tour) => tour.path);
for (const path of [
"/",
"/roster",
"/map",
"/operations",
"/logistics",
"/logistics/shipments",
"/logistics/banking",
"/logistics/market",
"/wiki",
"/awards",
"/locker",
]) {
expect(paths, `missing tour for ${path}`).toContain(path);
}
});
it("has unique tour ids and unique paths", () => {
expect(new Set(TOURS.map((t) => t.id)).size).toBe(TOURS.length);
expect(new Set(TOURS.map((t) => t.path)).size).toBe(TOURS.length);
});
it("gives every step a unique id and a data-tour anchor", () => {
for (const tour of TOURS) {
expect(tour.steps.length, `${tour.id} has steps`).toBeGreaterThan(0);
expect(new Set(tour.steps.map((s) => s.id)).size, tour.id).toBe(tour.steps.length);
for (const step of tour.steps) {
expect(step.selector, `${tour.id}/${step.id}`).toMatch(/^(\[data-tour=|html|body|#)/);
expect(step.body.length, `${tour.id}/${step.id}`).toBeGreaterThan(0);
expect(step.body, `${tour.id}/${step.id} copy is dash-clean`).not.toMatch(/[–—]/);
}
}
});
it("looks up tours by exact path and ignores query strings", () => {
expect(getTourForPath("/")?.id).toBe("dashboard");
expect(getTourForPath("/logistics/shipments")?.id).toBe("shipments");
expect(getTourForPath("/logistics/shipments?foo=bar")).toBeNull();
expect(getTourForPath("/no/such/page")).toBeNull();
expect(getTourForPath(null)).toBeNull();
expect(getTourForPath(undefined)).toBeNull();
});
});
describe("tour dismissals (per-browser localStorage)", () => {
const originalSetItem = window.localStorage.setItem.bind(window.localStorage);
const originalGetItem = window.localStorage.getItem.bind(window.localStorage);
beforeEach(() => {
window.localStorage.clear();
});
it("stores, reads, and reports dismissals", () => {
const now = 1_000_000;
expect(isTourDismissed("dashboard", now)).toBe(false);
storeTourDismissal("dashboard", now);
expect(isTourDismissed("dashboard", now)).toBe(true);
expect(isTourDismissed("other-tour", now)).toBe(false);
expect(readTourDismissals(now)).toEqual({ dashboard: now });
});
it("merges dismissals without losing other tours", () => {
const now = 5_000;
window.localStorage.clear();
writeTourDismissals({ a: now }, now);
storeTourDismissal("b", now + 1);
expect(readTourDismissals(now)).toEqual({ a: now, b: now + 1 });
});
it("degrades to an empty map when storage reads fail", () => {
window.localStorage.setItem = () => {
throw new Error("disabled");
};
window.localStorage.getItem = () => {
throw new Error("disabled");
};
expect(readTourDismissals(1)).toEqual({});
expect(isTourDismissed("dashboard", 1)).toBe(false);
// Writers swallow the failure instead of crashing the page.
expect(() => storeTourDismissal("dashboard", 1)).not.toThrow();
});
window.localStorage.setItem = originalSetItem;
window.localStorage.getItem = originalGetItem;
});
describe("tours opt-out (skip all)", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("defaults to opted-in and toggles per browser", () => {
expect(isToursOptedOut()).toBe(false);
setToursOptedOut(true);
expect(isToursOptedOut()).toBe(true);
setToursOptedOut(false);
expect(isToursOptedOut()).toBe(false);
});
it("keeps the opt-out independent of per-tour dismissals", () => {
setToursOptedOut(true);
storeTourDismissal("dashboard", 1);
setToursOptedOut(false);
expect(isTourDismissed("dashboard", 1)).toBe(true);
expect(isToursOptedOut()).toBe(false);
});
});