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 => ({ 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 => ({ 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(); 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: "

Semantic portfolio

" }, }); await flushPromises(); expect(wrapper.get("img").isVisible()).toBe(true); expect(wrapper.findAll("canvas")).toHaveLength(0); expect(wrapper.text()).toContain("Semantic portfolio"); wrapper.unmount(); }); 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(); expect(wrapper.get('[aria-label="Sound volume"]')).toBeTruthy(); expect(wrapper.get('[aria-label="Pause motion"]')).toBeTruthy(); const settings = wrapper.get('[aria-label="Drive settings"]'); await settings.trigger("click"); const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]'); 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"); }); }); 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"); }); });