509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
import { mount, flushPromises } from "@vue/test-utils";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { nextTick } from "vue";
|
|
import SceneCanvas from "@/components/SceneCanvas.vue";
|
|
import DriveControls from "@/components/DriveControls.vue";
|
|
import JourneyHud from "@/components/JourneyHud.vue";
|
|
import posterUrl from "@/assets/hero-chromatic.jpg";
|
|
import mobilePosterUrl from "@/assets/hero-chromatic-mobile.jpg";
|
|
import type { AudioUiState } from "@/audio/types";
|
|
import type { MotionPolicy, QualityTier } from "@/journey/motion-policy";
|
|
import type { JourneySnapshot } from "@/journey/types";
|
|
|
|
const sceneMocks = vi.hoisted(() => ({
|
|
build: vi.fn(async () => ({ render: vi.fn(), dispose: vi.fn() })),
|
|
}));
|
|
|
|
vi.mock("@/scene/world/environment", () => ({
|
|
createProceduralSceneGeneration: sceneMocks.build,
|
|
}));
|
|
|
|
const journey = (
|
|
overrides: Partial<JourneySnapshot> = {},
|
|
): JourneySnapshot => ({
|
|
scrollY: 0,
|
|
routeProgress: 0.42,
|
|
direction: 1,
|
|
acceleration: 0.3,
|
|
driveSpeed: 0.6,
|
|
activeSectionId: "infrastructure",
|
|
chapter: "infrastructure",
|
|
sign: null,
|
|
arrived: false,
|
|
sectionActivation: {
|
|
layoutRevision: 0,
|
|
activeHeadingOffsetVh: 0,
|
|
nextHeadingOffsetVh: null,
|
|
},
|
|
...overrides,
|
|
});
|
|
|
|
const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
|
|
allowSceneRequest: true,
|
|
allowAutomaticVideo: true,
|
|
maximumTier: "high",
|
|
reason: "full",
|
|
...overrides,
|
|
});
|
|
|
|
describe("SceneCanvas", () => {
|
|
beforeEach(() => sceneMocks.build.mockClear());
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"reduced motion",
|
|
policy({
|
|
allowSceneRequest: false,
|
|
maximumTier: "static",
|
|
reason: "system-reduce",
|
|
}),
|
|
],
|
|
[
|
|
"saved Static mode",
|
|
policy({
|
|
allowSceneRequest: false,
|
|
maximumTier: "static",
|
|
reason: "saved-static",
|
|
}),
|
|
],
|
|
])(
|
|
"does not request or render the scene for %s",
|
|
async (_name, blockedPolicy) => {
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: blockedPolicy,
|
|
quality: "static",
|
|
sceneDescription:
|
|
"A decorative night road behind the portfolio content.",
|
|
},
|
|
});
|
|
|
|
await flushPromises();
|
|
|
|
expect(sceneMocks.build).not.toHaveBeenCalled();
|
|
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
|
expect(wrapper.get("img").attributes()).toMatchObject({
|
|
alt: "",
|
|
src: posterUrl,
|
|
width: "1440",
|
|
height: "900",
|
|
});
|
|
expect(wrapper.get('picture source').attributes()).toMatchObject({
|
|
media: '(max-width: 767px)',
|
|
srcset: mobilePosterUrl,
|
|
});
|
|
expect(wrapper.get('.journey-world').attributes('data-static-fallback')).toBe('true');
|
|
expect(wrapper.text()).toContain(
|
|
"A decorative night road behind the portfolio content.",
|
|
);
|
|
wrapper.unmount();
|
|
},
|
|
);
|
|
|
|
it("owns exactly one decorative canvas when full motion is allowed", async () => {
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: policy(),
|
|
quality: "high",
|
|
sceneDescription:
|
|
"A decorative night road behind the portfolio content.",
|
|
},
|
|
});
|
|
|
|
await flushPromises();
|
|
|
|
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
|
expect(wrapper.findAll("canvas")).toHaveLength(1);
|
|
expect(wrapper.get("canvas").attributes("aria-hidden")).toBe("true");
|
|
expect(wrapper.get(".journey-world").attributes("aria-hidden")).toBe(
|
|
"true",
|
|
);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("forwards each complete scene frame interval to adaptive quality", async () => {
|
|
const callbacks = new Map<number, FrameRequestCallback>();
|
|
let nextFrameId = 1;
|
|
vi.stubGlobal(
|
|
"requestAnimationFrame",
|
|
vi.fn((callback: FrameRequestCallback) => {
|
|
const id = nextFrameId++;
|
|
callbacks.set(id, callback);
|
|
return id;
|
|
}),
|
|
);
|
|
vi.stubGlobal(
|
|
"cancelAnimationFrame",
|
|
vi.fn((id: number) => callbacks.delete(id)),
|
|
);
|
|
const sampleFrame = vi.fn();
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: policy(),
|
|
quality: "high",
|
|
sceneDescription:
|
|
"A decorative night road behind the portfolio content.",
|
|
sampleFrame,
|
|
},
|
|
});
|
|
await flushPromises();
|
|
const frame = [...callbacks.entries()][0]!;
|
|
callbacks.delete(frame[0]);
|
|
vi.spyOn(performance, "now")
|
|
.mockReturnValueOnce(2_000)
|
|
.mockReturnValueOnce(2_301);
|
|
|
|
frame[1](2_000);
|
|
|
|
expect(sampleFrame).toHaveBeenCalledWith(2_000, 2_301);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("falls back to the poster without obscuring semantic content after scene failure", async () => {
|
|
sceneMocks.build.mockRejectedValueOnce(new Error("WebGL unavailable"));
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: policy(),
|
|
quality: "medium",
|
|
sceneDescription:
|
|
"A decorative night road behind the portfolio content.",
|
|
},
|
|
slots: { default: "<main><h1>Semantic portfolio</h1></main>" },
|
|
});
|
|
|
|
await flushPromises();
|
|
|
|
expect(wrapper.get("img").isVisible()).toBe(true);
|
|
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
|
expect(wrapper.text()).toContain("Semantic portfolio");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("writes html[data-dawn] without the scene only when the step changes", async () => {
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: policy({ allowSceneRequest: false, maximumTier: "static", reason: "system-reduce" }),
|
|
quality: "static",
|
|
sceneDescription: "A decorative night road behind the portfolio content.",
|
|
},
|
|
});
|
|
await flushPromises();
|
|
expect(document.documentElement.dataset.dawn).toBe("night");
|
|
const writes: string[] = [];
|
|
const observer = new MutationObserver((records) => {
|
|
for (const record of records) if (record.attributeName === "data-dawn") writes.push(document.documentElement.dataset.dawn ?? "");
|
|
});
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-dawn"] });
|
|
// A scroll replaces the snapshot every frame; the step does not change.
|
|
for (let frame = 1; frame <= 6; frame++) {
|
|
await wrapper.setProps({ journey: journey({ scrollY: frame * 40, routeProgress: 0.42 + frame / 100 }) });
|
|
await flushPromises();
|
|
}
|
|
await wrapper.setProps({ journey: journey({ scrollY: 9000, routeProgress: 1, arrived: true }) });
|
|
await flushPromises();
|
|
observer.disconnect();
|
|
expect(writes).toEqual(["day"]);
|
|
wrapper.unmount();
|
|
expect(document.documentElement.dataset.dawn).toBeUndefined();
|
|
});
|
|
|
|
it("aborts and removes the canvas on a Static transition", async () => {
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: policy(),
|
|
quality: "high",
|
|
sceneDescription:
|
|
"A decorative night road behind the portfolio content.",
|
|
},
|
|
});
|
|
await flushPromises();
|
|
expect(wrapper.findAll("canvas")).toHaveLength(1);
|
|
|
|
await wrapper.setProps({
|
|
policy: policy({
|
|
allowSceneRequest: false,
|
|
maximumTier: "static",
|
|
reason: "saved-static",
|
|
}),
|
|
quality: "static",
|
|
});
|
|
await nextTick();
|
|
|
|
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
|
expect(wrapper.get("img").isVisible()).toBe(true);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("retains the same canvas and scene generation for a visitor pause and resume", async () => {
|
|
const wrapper = mount(SceneCanvas, {
|
|
props: {
|
|
journey: journey(),
|
|
policy: policy(),
|
|
quality: "high",
|
|
sceneDescription:
|
|
"A decorative night road behind the portfolio content.",
|
|
},
|
|
});
|
|
await flushPromises();
|
|
const canvas = wrapper.get("canvas").element;
|
|
const runtime = await sceneMocks.build.mock.results[0]!.value;
|
|
|
|
await wrapper.setProps({
|
|
policy: policy({
|
|
allowSceneRequest: false,
|
|
maximumTier: "static",
|
|
reason: "visitor-pause",
|
|
}),
|
|
quality: "high",
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(wrapper.get("canvas").element).toBe(canvas);
|
|
expect(runtime.dispose).not.toHaveBeenCalled();
|
|
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
|
|
|
await wrapper.setProps({ policy: policy() });
|
|
await flushPromises();
|
|
|
|
expect(wrapper.get("canvas").element).toBe(canvas);
|
|
expect(runtime.dispose).not.toHaveBeenCalled();
|
|
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe("DriveControls", () => {
|
|
const mountControls = (
|
|
motionPolicy: MotionPolicy,
|
|
maximum: QualityTier = "high",
|
|
audioState: AudioUiState = { kind: "idle" },
|
|
) =>
|
|
mount(DriveControls, {
|
|
attachTo: document.body,
|
|
props: {
|
|
policy: motionPolicy,
|
|
maximumQuality: maximum,
|
|
sceneStatus: "running",
|
|
audioState,
|
|
},
|
|
});
|
|
|
|
it("presents one explicit soundtrack action before audio activation", async () => {
|
|
const wrapper = mountControls(policy());
|
|
|
|
expect(wrapper.get('[data-drive-state="idle"]').text()).toContain(
|
|
"PLAY SOUNDTRACK",
|
|
);
|
|
expect(wrapper.find('[aria-label="Mute sound"]').exists()).toBe(false);
|
|
await wrapper.get('[aria-label="Play soundtrack"]').trigger("click");
|
|
expect(wrapper.emitted("audioStart")).toHaveLength(1);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("collapses the running state to named vehicle controls and an upward settings panel", async () => {
|
|
const wrapper = mountControls(policy(), "high", {
|
|
kind: "running",
|
|
muted: false,
|
|
volume: 0.4,
|
|
});
|
|
|
|
expect(wrapper.get('[aria-label="Mute sound"]')).toBeTruthy();
|
|
const settings = wrapper.get('[aria-label="Drive settings"]');
|
|
await settings.trigger("click");
|
|
|
|
// Volume and Pause motion live in the settings panel (two-button bay).
|
|
const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]');
|
|
expect(panel.get('[aria-label="Sound volume level"]')).toBeTruthy();
|
|
expect(panel.get('[aria-label="Pause motion"]')).toBeTruthy();
|
|
expect(panel.text()).not.toContain("Motion: active");
|
|
expect(panel.find('[role="status"]').exists()).toBe(false);
|
|
expect(panel.get("select").attributes("aria-label")).toBe(
|
|
"Quality control",
|
|
);
|
|
expect(settings.attributes("aria-expanded")).toBe("true");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("dismisses settings with Escape and outside interaction", async () => {
|
|
const wrapper = mountControls(policy(), "high", {
|
|
kind: "running",
|
|
muted: false,
|
|
volume: 0.4,
|
|
});
|
|
const settings = wrapper.get('[aria-label="Drive settings"]');
|
|
await settings.trigger("click");
|
|
document.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
);
|
|
await nextTick();
|
|
expect(
|
|
wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists(),
|
|
).toBe(false);
|
|
expect(document.activeElement).toBe(settings.element);
|
|
|
|
await settings.trigger("click");
|
|
document.body.dispatchEvent(
|
|
new MouseEvent("pointerdown", { bubbles: true }),
|
|
);
|
|
await nextTick();
|
|
expect(
|
|
wrapper.find('[role="dialog"][aria-label="Drive settings"]').exists(),
|
|
).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it.each([
|
|
["visitor-pause", "Resume motion"],
|
|
["saved-static", "Enable motion"],
|
|
] as const)("offers the correct recovery action for %s", (reason, label) => {
|
|
const wrapper = mountControls(
|
|
policy({ allowSceneRequest: false, maximumTier: "static", reason }),
|
|
"static",
|
|
{ kind: "running", muted: false, volume: 0.4 },
|
|
);
|
|
expect(wrapper.get(`[aria-label="${label}"]`)).toBeTruthy();
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it.each([
|
|
["visitor-pause", "Resume motion"],
|
|
["saved-static", "Enable motion"],
|
|
] as const)(
|
|
"keeps the %s recovery action available before audio starts",
|
|
(reason, label) => {
|
|
const wrapper = mountControls(
|
|
policy({ allowSceneRequest: false, maximumTier: "static", reason }),
|
|
"static",
|
|
);
|
|
expect(wrapper.get(`[aria-label="${label}"]`)).toBeTruthy();
|
|
wrapper.unmount();
|
|
},
|
|
);
|
|
|
|
it("announces a nondefault scene state without requiring the settings panel", () => {
|
|
const wrapper = mount(DriveControls, {
|
|
props: {
|
|
policy: policy(),
|
|
maximumQuality: "high",
|
|
sceneStatus: "failed",
|
|
audioState: { kind: "idle" },
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find('[role="dialog"]').exists()).toBe(false);
|
|
expect(wrapper.get('[role="status"]').text()).toBe("Visuals unavailable");
|
|
});
|
|
|
|
it("disables the quality choice and reads Static while visuals are unavailable", async () => {
|
|
const wrapper = mount(DriveControls, {
|
|
props: {
|
|
policy: policy(),
|
|
maximumQuality: "high",
|
|
sceneStatus: "failed",
|
|
audioState: { kind: "idle" },
|
|
},
|
|
});
|
|
await wrapper.get('[aria-label="Drive settings"]').trigger("click");
|
|
const select = wrapper.get<HTMLSelectElement>('[aria-label="Quality control"]');
|
|
expect(select.element.disabled).toBe(true);
|
|
expect(select.element.value).toBe("static");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it.each(["system-reduce", "data-saver"] as const)(
|
|
"disables the quality choice and reads Static under %s",
|
|
async (reason) => {
|
|
const wrapper = mount(DriveControls, {
|
|
props: {
|
|
policy: policy({ reason, allowSceneRequest: false, allowAutomaticVideo: false, maximumTier: "static" }),
|
|
maximumQuality: "high",
|
|
sceneStatus: "idle",
|
|
audioState: { kind: "idle" },
|
|
},
|
|
});
|
|
await wrapper.get('[aria-label="Drive settings"]').trigger("click");
|
|
const select = wrapper.get<HTMLSelectElement>('[aria-label="Quality control"]');
|
|
expect(select.element.disabled).toBe(true);
|
|
expect(select.element.value).toBe("static");
|
|
wrapper.unmount();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("JourneyHud", () => {
|
|
it("renders only route percent, current section, and arrival from one snapshot", () => {
|
|
const wrapper = mount(JourneyHud, {
|
|
props: {
|
|
journey: journey({
|
|
routeProgress: 1,
|
|
activeSectionId: "contact",
|
|
arrived: true,
|
|
}),
|
|
sectionLabel: "Contact",
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("100%");
|
|
expect(wrapper.text()).toContain("Contact");
|
|
expect(wrapper.text()).toContain("ARRIVED");
|
|
expect(wrapper.text()).not.toContain("Low");
|
|
expect(wrapper.attributes("aria-hidden")).toBe("true");
|
|
});
|
|
|
|
it("hangs the maintenance sub-line under the plate only when one is given", () => {
|
|
const quiet = mount(JourneyHud, {
|
|
props: { journey: journey({ activeSectionId: "experience" }), sectionLabel: "Experience" },
|
|
});
|
|
expect(quiet.get(".journey-hud__subline").text()).toBe("");
|
|
quiet.unmount();
|
|
|
|
const window = mount(JourneyHud, {
|
|
props: {
|
|
journey: journey({ activeSectionId: "infrastructure" }),
|
|
sectionLabel: "Infrastructure",
|
|
subline: "MAINTENANCE WINDOW \u00b7 03:12",
|
|
},
|
|
});
|
|
expect(window.get(".journey-hud__subline").text()).toBe("MAINTENANCE WINDOW \u00b7 03:12");
|
|
window.unmount();
|
|
});
|
|
|
|
it("rolls the ROUTE LOG index on odometer digits keyed by value", async () => {
|
|
const hud = mount(JourneyHud, {
|
|
props: {
|
|
journey: journey({ activeSectionId: "experience" }),
|
|
sectionLabel: "Experience",
|
|
subline: "LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218",
|
|
},
|
|
});
|
|
const subline = hud.get(".journey-hud__subline");
|
|
expect(subline.text()).toBe("LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218");
|
|
expect(hud.findAll(".route-odo__digit").map((digit) => digit.text())).toEqual(["0", "1"]);
|
|
const tens = hud.findAll(".route-odo__digit")[0]!.element;
|
|
const ones = hud.findAll(".route-odo__digit")[1]!.element;
|
|
const pulse = hud.get(".journey-hud__log-pulse").element;
|
|
await hud.setProps({ subline: "LOG 02/04 \u00b7 JUN 2025 \u2192 AUG 2025 \u00b7 CCF \u00b7 REWIND" });
|
|
const digits = hud.findAll(".route-odo__digit");
|
|
expect(digits.map((digit) => digit.text())).toEqual(["0", "2"]);
|
|
expect(digits[0]!.element).toBe(tens);
|
|
expect(digits[1]!.element).not.toBe(ones);
|
|
expect(hud.get(".journey-hud__log-pulse").element).not.toBe(pulse);
|
|
expect(hud.get(".journey-hud__subline").attributes("data-rewind")).toBe("true");
|
|
await hud.setProps({ subline: "" });
|
|
expect(hud.findAll(".route-odo").length).toBe(0);
|
|
expect(hud.find(".journey-hud__log-pulse").exists()).toBe(false);
|
|
expect(hud.get(".journey-hud__subline").text()).toBe("");
|
|
hud.unmount();
|
|
});
|
|
});
|