import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { FakeDocument, type FakeElement, fakeStorage } from "./fakeDom.ts"; import { ONBOARDING_STEPS, ONBOARDING_STORAGE_KEY, hasSeenOnboarding, markOnboardingSeen, mountOnboarding, } from "../../ui/onboarding.ts"; import type { OnboardingStorage } from "../../ui/onboarding.ts"; function setup(storage: OnboardingStorage | null, coarsePointer = false, force = false) { const doc = new FakeDocument(); const host = doc.createElement("div"); doc.body.append(host); const finished: string[] = []; const coach = mountOnboarding(host as unknown as HTMLElement, { coarsePointer, storage, force, onFinish: (reason) => finished.push(reason), }); return { doc, host, coach, finished, root: coach.root as unknown as FakeElement | null }; } describe("the first-run coach", () => { it("teaches the two verbs the product is, and the control surface that reaches them", () => { // The product is "fly the board" and "walk into the studio", and before this // file neither was ever stated on screen at any tier in any layout. const verbs = ONBOARDING_STEPS.map((step) => step.verb.toLowerCase()); assert.equal(ONBOARDING_STEPS.length, 3); assert.ok(verbs.some((verb) => verb.includes("fly"))); assert.ok(verbs.some((verb) => verb.includes("walk into"))); assert.ok(verbs.some((verb) => verb.includes("controls"))); }); it("gives every step a touch sentence that is a different act, not a translation", () => { // The desktop's key strip is `display: none` below 600px, so on a phone this // is the only place the controls are ever named. for (const step of ONBOARDING_STEPS) { assert.ok(step.touch.trim().length > 10, `${step.id} has no touch sentence`); assert.notEqual(step.touch, step.pointer, `${step.id} just restates its pointer sentence`); } const dock = ONBOARDING_STEPS.find((step) => step.id === "dock"); // The joystick is the control a phone visitor otherwise had no way to find. assert.match(dock?.touch ?? "", /joystick/); }); it("appears when storage is empty", () => { const storage = fakeStorage(); const { coach, root, host } = setup(storage); assert.equal(coach.visible, true); assert.ok(root); assert.equal(host.children.length, 1); assert.match(root.text(), /Fly the board/); }); it("does not appear on the second mount", () => { const storage = fakeStorage(); const first = setup(storage); assert.equal(first.coach.visible, true); const second = setup(storage); assert.equal(second.coach.visible, false); assert.equal(second.root, null); assert.equal(second.host.children.length, 0, "a returning visitor's page carries no trace"); }); it("records the visit at mount, not at completion", () => { /* * Deliberate. Somebody who read the first card and went straight to dragging * the map has been onboarded; a coach that reappears next visit because they * did not press "Next" three times is arguing with its own success * condition. */ const storage = fakeStorage(); setup(storage); assert.equal(storage.map.has(ONBOARDING_STORAGE_KEY), true); }); it("still renders when every localStorage accessor throws", () => { // Safari private mode, and any embed with third-party storage blocked. An // onboarding card is not worth a blank page. const storage = fakeStorage("throwing"); assert.doesNotThrow(() => hasSeenOnboarding(storage)); assert.equal(hasSeenOnboarding(storage), false); assert.doesNotThrow(() => markOnboardingSeen(storage)); const { coach, root } = setup(storage); assert.equal(coach.visible, true); assert.ok(root); // And it keeps working: a throwing store means it will be offered again next // visit, which is survivable in a way that never showing it is not. const again = setup(storage); assert.equal(again.coach.visible, true); }); it("treats a missing store the same way", () => { assert.equal(hasSeenOnboarding(null), false); assert.doesNotThrow(() => markOnboardingSeen(null)); const { coach } = setup(null); assert.equal(coach.visible, true); }); it("steps forward, marks progress, and finishes on the last card", () => { const { coach, root, finished } = setup(fakeStorage()); assert.ok(root); assert.equal(coach.index(), 0); coach.next(); assert.equal(coach.index(), 1); assert.match(root.text(), /Take the controls/); coach.next(); assert.equal(coach.index(), 2); assert.match(root.text(), /Walk into the studio/); assert.match(root.text(), /Start exploring/); coach.next(); assert.equal(coach.index(), -1); assert.equal(root.hidden, true); assert.deepEqual(finished, ["finished"]); // Idempotent: a double-press of the last button must not fire twice. coach.next(); assert.deepEqual(finished, ["finished"]); }); it("is skippable from any card, in one press", () => { const { coach, root, finished } = setup(fakeStorage()); assert.ok(root); coach.next(); const skip = root.querySelector(".tera-coach__skip"); assert.ok(skip); skip.click(); assert.equal(root.hidden, true); assert.equal(coach.index(), -1); assert.deepEqual(finished, ["skipped"]); }); it("rings the control each step is naming, and stops when it is done", () => { // A coach that greys the scene to point at the scene has explained nothing. // This is a card in a corner plus one attribute on . const { doc, coach } = setup(fakeStorage()); assert.equal(doc.body.getAttribute("data-coach"), null, "step one is about the whole scene"); coach.next(); assert.equal(doc.body.getAttribute("data-coach"), "mode-dock"); coach.next(); assert.equal(doc.body.getAttribute("data-coach"), "enter"); coach.skip(); assert.equal(doc.body.getAttribute("data-coach"), null); }); it("leads with the touch sentence on a coarse pointer", () => { const withMouse = setup(fakeStorage(), false); assert.ok(withMouse.root); assert.match(withMouse.root.text(), /scroll to zoom/); const withThumb = setup(fakeStorage(), true); assert.ok(withThumb.root); assert.match(withThumb.root.text(), /pinch to zoom/); }); it("can be shown again on request, without a second copy of the stylesheet", () => { const storage = fakeStorage(); const first = setup(storage); first.coach.dispose(); const again = setup(storage, false, true); assert.equal(again.coach.visible, true); assert.equal(again.doc.head.querySelectorAll("style").length, 1); }); it("leaves nothing behind on dispose", () => { const { doc, host, coach } = setup(fakeStorage()); coach.next(); assert.equal(doc.body.getAttribute("data-coach"), "mode-dock"); coach.dispose(); assert.equal(host.children.length, 0); assert.equal(doc.body.getAttribute("data-coach"), null); assert.equal(doc.head.querySelectorAll("style").length, 0); }); });