mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Step 2 (sidebar) left the user "stuck looking at a glow with nothing to do": the sidebar is a full-height column, so the below/above-only placement computed a popover position past the bottom of the viewport — the card existed but was invisible off-screen. Placement is now a pure, exported decision (computeTourPlacement): try below → above → beside-right → beside-left, whichever actually fits the popover, with a centered-over-the-dim fallback when nothing does (viewport-filling targets) — and coordinates are clamped so the card can NEVER leave the viewport. The sidebar step also hints placement: "right". A preferred placement that doesn't fit falls back to one that does. Tests: +1 in tests/onboarding-tour.test.mjs — full-height column → beside-right inside the viewport; button → bottom; near-bottom target → top; viewport-filling target → centered; unfittable preference falls back and never goes below the fold. Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
233 lines
8.5 KiB
JavaScript
233 lines
8.5 KiB
JavaScript
// First-run onboarding (docs/onboarding-plan.md): the per-pubkey once flag and
|
|
// the tour engine's lifecycle (spotlight + popover + next/skip + auto-skipping
|
|
// steps whose anchor is missing), driven against real JSDOM.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-onboarding-first-run
|
|
// given: "a fresh pubkey / a completed pubkey; a DOM with some tour anchors"
|
|
// when: "shouldOfferOnboarding + launchBitvidTour/createTour run"
|
|
// then: "offered once; completing/skipping records the flag; steps with missing anchors are skipped; force reruns"
|
|
// observable_outcomes:
|
|
// - "fresh pubkey -> offer true; completed/skipped -> offer false; force -> starts anyway"
|
|
// - "tour renders overlay + popover; Next advances; Done finishes -> completed flag"
|
|
// - "Skip records skipped and removes the overlay"
|
|
// - "steps with absent targets are excluded from the dot count"
|
|
// determinism_controls:
|
|
// - "JSDOM; in-memory localStorage polyfill; no timers beyond direct calls"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
import "./test-helpers/setup-localstorage.mjs";
|
|
import test, { beforeEach } from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import { JSDOM } from "jsdom";
|
|
import {
|
|
shouldOfferOnboarding,
|
|
markOnboarding,
|
|
resetOnboarding,
|
|
} from "../js/services/onboardingService.js";
|
|
import { createTour } from "../js/ui/onboarding/tourEngine.js";
|
|
import { launchBitvidTour } from "../js/ui/onboarding/bitvidTour.js";
|
|
|
|
const PUB = "a".repeat(64);
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
test("flag: offered when fresh; not after completed/skipped; reset restores", () => {
|
|
assert.equal(shouldOfferOnboarding(PUB), true);
|
|
markOnboarding(PUB, "completed");
|
|
assert.equal(shouldOfferOnboarding(PUB), false);
|
|
resetOnboarding(PUB);
|
|
assert.equal(shouldOfferOnboarding(PUB), true);
|
|
markOnboarding(PUB, "skipped");
|
|
assert.equal(shouldOfferOnboarding(PUB), false);
|
|
assert.equal(shouldOfferOnboarding(""), false, "no pubkey never offers");
|
|
});
|
|
|
|
function makeDom() {
|
|
const dom = new JSDOM(
|
|
'<!DOCTYPE html><body><nav id="sidebar"></nav><button data-testid="upload-button"></button></body>',
|
|
{ pretendToBeVisual: true },
|
|
);
|
|
return dom.window.document;
|
|
}
|
|
|
|
test("tour lifecycle: renders, advances, Done records completion", () => {
|
|
const doc = makeDom();
|
|
let finished = false;
|
|
const tour = createTour({
|
|
document: doc,
|
|
steps: [
|
|
{ id: "welcome", title: "Hi", body: "b" },
|
|
{ id: "feeds", target: "#sidebar", title: "Feeds", body: "b" },
|
|
{ id: "gone", target: "#does-not-exist", title: "Missing", body: "b" },
|
|
{ id: "upload", target: '[data-testid="upload-button"]', title: "Up", body: "b" },
|
|
],
|
|
onFinish: () => {
|
|
finished = true;
|
|
},
|
|
});
|
|
|
|
assert.equal(tour.start(), true);
|
|
const popover = doc.querySelector(".bv-tour-popover");
|
|
assert.ok(popover, "popover rendered");
|
|
assert.ok(doc.querySelector(".bv-tour-scrim"), "spotlight scrim rendered");
|
|
assert.equal(
|
|
doc.querySelectorAll(".bv-tour-dots span").length,
|
|
3,
|
|
"missing-anchor step excluded from progress dots",
|
|
);
|
|
assert.match(popover.querySelector("h3").textContent, /Hi/);
|
|
|
|
// Advance through all available steps via the primary button.
|
|
const clickPrimary = () => {
|
|
const buttons = Array.from(popover.querySelectorAll("button"));
|
|
buttons[buttons.length - 1].click();
|
|
};
|
|
clickPrimary(); // -> feeds
|
|
assert.match(popover.querySelector("h3").textContent, /Feeds/);
|
|
clickPrimary(); // -> upload (skips missing)
|
|
assert.match(popover.querySelector("h3").textContent, /Up/);
|
|
clickPrimary(); // Done
|
|
assert.equal(finished, true, "onFinish fired");
|
|
assert.equal(doc.querySelector(".bv-tour-root"), null, "overlay removed");
|
|
});
|
|
|
|
test("Skip records skipped and tears down", () => {
|
|
const doc = makeDom();
|
|
let skipped = false;
|
|
const tour = createTour({
|
|
document: doc,
|
|
steps: [{ id: "welcome", title: "Hi", body: "b" }],
|
|
onSkip: () => {
|
|
skipped = true;
|
|
},
|
|
});
|
|
tour.start();
|
|
doc.querySelector(".bv-tour-skip").click();
|
|
assert.equal(skipped, true);
|
|
assert.equal(doc.querySelector(".bv-tour-root"), null);
|
|
});
|
|
|
|
test("launchBitvidTour: offers once per pubkey, force reruns, records the flag", () => {
|
|
const doc = makeDom();
|
|
|
|
assert.equal(
|
|
launchBitvidTour({ pubkey: PUB, document: doc }),
|
|
true,
|
|
"fresh pubkey starts the tour",
|
|
);
|
|
// Skip it → flag recorded.
|
|
doc.querySelector(".bv-tour-skip").click();
|
|
assert.equal(shouldOfferOnboarding(PUB), false, "skip recorded");
|
|
|
|
assert.equal(
|
|
launchBitvidTour({ pubkey: PUB, document: doc }),
|
|
false,
|
|
"not offered again automatically",
|
|
);
|
|
assert.equal(
|
|
launchBitvidTour({ pubkey: PUB, document: doc, force: true }),
|
|
true,
|
|
"force ('Take the tour') reruns it",
|
|
);
|
|
doc.querySelector(".bv-tour-skip").click();
|
|
});
|
|
|
|
test("final-card action buttons deep-link and complete the tour", () => {
|
|
const doc = makeDom();
|
|
const panes = [];
|
|
launchBitvidTour({
|
|
pubkey: PUB,
|
|
document: doc,
|
|
openProfilePane: (pane) => panes.push(pane),
|
|
});
|
|
|
|
// Jump to the last step by clicking primary until the actions appear.
|
|
const popover = doc.querySelector(".bv-tour-popover");
|
|
for (let i = 0; i < 10 && doc.querySelector(".bv-tour-root"); i += 1) {
|
|
const buttons = Array.from(popover.querySelectorAll("button"));
|
|
const storage = buttons.find((b) => /Set up storage/.test(b.textContent));
|
|
if (storage) {
|
|
storage.click();
|
|
break;
|
|
}
|
|
buttons[buttons.length - 1].click();
|
|
}
|
|
|
|
assert.deepEqual(panes, ["storage"], "deep-linked into the storage pane");
|
|
assert.equal(doc.querySelector(".bv-tour-root"), null, "tour closed");
|
|
assert.equal(shouldOfferOnboarding(PUB), false, "completion recorded");
|
|
});
|
|
|
|
// Regression (the "stuck at the sidebar" bug): the popover placement must NEVER
|
|
// land outside the viewport. A full-height target (sidebar) used to push the
|
|
// popover below the fold, leaving the user with a glowing sidebar and no card.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-tour-placement
|
|
// given: "target rects of various shapes vs a viewport"
|
|
// when: "computeTourPlacement decides where the popover goes"
|
|
// then: "it picks a side that fits and always stays inside the viewport"
|
|
// observable_outcomes:
|
|
// - "full-height left column -> 'right' placement, y within viewport"
|
|
// - "normal button with room below -> 'bottom'"
|
|
// - "target near the bottom edge with room above -> 'top'"
|
|
// - "viewport-filling target -> 'center' fallback (always visible)"
|
|
// determinism_controls:
|
|
// - "pure function, explicit rect/viewport inputs"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["asserting the off-screen (broken) coordinates"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
test("placement: full-height sidebar goes BESIDE, never off-screen", async () => {
|
|
const { computeTourPlacement } = await import("../js/ui/onboarding/tourEngine.js");
|
|
const viewport = { viewportWidth: 1280, viewportHeight: 800 };
|
|
|
|
const sidebar = computeTourPlacement({
|
|
rect: { left: 0, top: 64, right: 240, bottom: 800, width: 240, height: 736 },
|
|
...viewport,
|
|
});
|
|
assert.equal(sidebar.placement, "right");
|
|
assert.ok(sidebar.py > 0 && sidebar.py < 800, "y inside the viewport");
|
|
assert.ok(sidebar.px > 240 && sidebar.px < 1280, "x beside the sidebar");
|
|
|
|
const button = computeTourPlacement({
|
|
rect: { left: 600, top: 100, right: 700, bottom: 140, width: 100, height: 40 },
|
|
...viewport,
|
|
});
|
|
assert.equal(button.placement, "bottom");
|
|
|
|
const nearBottom = computeTourPlacement({
|
|
rect: { left: 600, top: 700, right: 700, bottom: 780, width: 100, height: 80 },
|
|
...viewport,
|
|
});
|
|
assert.equal(nearBottom.placement, "top");
|
|
|
|
const fullScreen = computeTourPlacement({
|
|
rect: { left: 0, top: 0, right: 1280, bottom: 800, width: 1280, height: 800 },
|
|
...viewport,
|
|
});
|
|
assert.equal(fullScreen.placement, "center");
|
|
assert.equal(fullScreen.px, 640);
|
|
assert.equal(fullScreen.py, 400);
|
|
|
|
// A preferred placement that doesn't fit falls back to one that does.
|
|
const badPreference = computeTourPlacement({
|
|
rect: { left: 0, top: 64, right: 240, bottom: 800, width: 240, height: 736 },
|
|
preferred: "bottom",
|
|
...viewport,
|
|
});
|
|
assert.notEqual(badPreference.placement, "bottom");
|
|
assert.ok(badPreference.py <= 800 - 12, "never below the fold");
|
|
});
|