fix: close final portfolio quality gaps
This commit is contained in:
@@ -1,29 +1,31 @@
|
||||
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 type { AudioUiState } from '@/audio/types';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
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 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', () => ({
|
||||
vi.mock("@/scene/world/environment", () => ({
|
||||
createProceduralSceneGeneration: sceneMocks.build,
|
||||
}));
|
||||
|
||||
const journey = (overrides: Partial<JourneySnapshot> = {}): JourneySnapshot => ({
|
||||
const journey = (
|
||||
overrides: Partial<JourneySnapshot> = {},
|
||||
): JourneySnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0.42,
|
||||
direction: 1,
|
||||
acceleration: 0.3,
|
||||
driveSpeed: 0.6,
|
||||
activeSectionId: 'infrastructure',
|
||||
chapter: 'infrastructure',
|
||||
activeSectionId: "infrastructure",
|
||||
chapter: "infrastructure",
|
||||
sign: null,
|
||||
arrived: false,
|
||||
...overrides,
|
||||
@@ -32,12 +34,12 @@ const journey = (overrides: Partial<JourneySnapshot> = {}): JourneySnapshot => (
|
||||
const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
|
||||
allowSceneRequest: true,
|
||||
allowAutomaticVideo: true,
|
||||
maximumTier: 'high',
|
||||
reason: 'full',
|
||||
maximumTier: "high",
|
||||
reason: "full",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SceneCanvas', () => {
|
||||
describe("SceneCanvas", () => {
|
||||
beforeEach(() => sceneMocks.build.mockClear());
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -45,73 +47,111 @@ describe('SceneCanvas', () => {
|
||||
});
|
||||
|
||||
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.',
|
||||
},
|
||||
});
|
||||
[
|
||||
"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();
|
||||
await flushPromises();
|
||||
|
||||
expect(sceneMocks.build).not.toHaveBeenCalled();
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(0);
|
||||
expect(wrapper.get('img').attributes()).toMatchObject({ alt: '', width: '1600', height: '900' });
|
||||
expect(wrapper.findAll('.journey-poster-composition > span')).toHaveLength(7);
|
||||
expect(wrapper.find('.journey-poster__sun').exists()).toBe(true);
|
||||
expect(wrapper.find('.journey-poster__road').exists()).toBe(true);
|
||||
expect(wrapper.find('.journey-poster__car').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('A decorative night road behind the portfolio content.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
expect(sceneMocks.build).not.toHaveBeenCalled();
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
||||
expect(wrapper.get("img").attributes()).toMatchObject({
|
||||
alt: "",
|
||||
width: "1600",
|
||||
height: "900",
|
||||
});
|
||||
expect(
|
||||
wrapper.findAll(".journey-poster-composition > span"),
|
||||
).toHaveLength(7);
|
||||
expect(wrapper.find(".journey-poster__sun").exists()).toBe(true);
|
||||
expect(wrapper.find(".journey-poster__road").exists()).toBe(true);
|
||||
expect(wrapper.find(".journey-poster__car").exists()).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 () => {
|
||||
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.',
|
||||
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');
|
||||
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 () => {
|
||||
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)));
|
||||
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.',
|
||||
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);
|
||||
vi.spyOn(performance, "now")
|
||||
.mockReturnValueOnce(2_000)
|
||||
.mockReturnValueOnce(2_301);
|
||||
|
||||
frame[1](2_000);
|
||||
|
||||
@@ -119,162 +159,232 @@ describe('SceneCanvas', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('falls back to the poster without obscuring semantic content after scene failure', async () => {
|
||||
sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable'));
|
||||
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.',
|
||||
quality: "medium",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
slots: { default: '<main><h1>Semantic portfolio</h1></main>' },
|
||||
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');
|
||||
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 () => {
|
||||
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.',
|
||||
quality: "high",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(1);
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(1);
|
||||
|
||||
await wrapper.setProps({
|
||||
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' }),
|
||||
quality: 'static',
|
||||
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);
|
||||
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 () => {
|
||||
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.',
|
||||
quality: "high",
|
||||
sceneDescription:
|
||||
"A decorative night road behind the portfolio content.",
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const canvas = wrapper.get('canvas').element;
|
||||
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',
|
||||
policy: policy({
|
||||
allowSceneRequest: false,
|
||||
maximumTier: "static",
|
||||
reason: "visitor-pause",
|
||||
}),
|
||||
quality: "high",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('canvas').element).toBe(canvas);
|
||||
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(wrapper.get("canvas").element).toBe(canvas);
|
||||
expect(runtime.dispose).not.toHaveBeenCalled();
|
||||
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriveControls', () => {
|
||||
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 },
|
||||
});
|
||||
maximum: QualityTier = "high",
|
||||
audioState: AudioUiState = { kind: "idle" },
|
||||
) =>
|
||||
mount(DriveControls, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
policy: motionPolicy,
|
||||
maximumQuality: maximum,
|
||||
sceneStatus: "running",
|
||||
audioState,
|
||||
},
|
||||
});
|
||||
|
||||
it('presents one explicit drive action before audio activation', async () => {
|
||||
it("presents one explicit drive action before audio activation", async () => {
|
||||
const wrapper = mountControls(policy());
|
||||
|
||||
expect(wrapper.get('[data-drive-state="idle"]').text()).toContain('START THE DRIVE');
|
||||
expect(wrapper.get('[data-drive-state="idle"]').text()).toContain(
|
||||
"START THE DRIVE",
|
||||
);
|
||||
expect(wrapper.find('[aria-label="Mute sound"]').exists()).toBe(false);
|
||||
await wrapper.get('[aria-label="Start the drive"]').trigger('click');
|
||||
expect(wrapper.emitted('audioStart')).toHaveLength(1);
|
||||
await wrapper.get('[aria-label="Start the drive"]').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 });
|
||||
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');
|
||||
await settings.trigger("click");
|
||||
|
||||
const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]');
|
||||
expect(panel.text()).not.toContain('Motion: active');
|
||||
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');
|
||||
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 });
|
||||
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 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(
|
||||
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 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);
|
||||
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) => {
|
||||
["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 },
|
||||
policy({ allowSceneRequest: false, maximumTier: "static", reason }),
|
||||
"static",
|
||||
{ kind: "running", muted: false, volume: 0.4 },
|
||||
);
|
||||
expect(wrapper.get(`[aria-label="${label}"]`)).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyHud', () => {
|
||||
it('renders only route percent, current section, and arrival from one snapshot', () => {
|
||||
const wrapper = mount(JourneyHud, {
|
||||
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: {
|
||||
journey: journey({ routeProgress: 1, activeSectionId: 'contact', arrived: true }),
|
||||
sectionLabel: 'Contact',
|
||||
policy: policy(),
|
||||
maximumQuality: "high",
|
||||
sceneStatus: "failed",
|
||||
audioState: { kind: "idle" },
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user