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).
192 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|