332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { flushPromises, mount } from '@vue/test-utils';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { defineComponent, h, onMounted, ref, type Component } from 'vue';
|
|
import DashcamFrame from '@/components/DashcamFrame.vue';
|
|
import DriveControls from '@/components/DriveControls.vue';
|
|
import { DASHCAM_LOAD_TIMEOUT_MS, DASHCAM_RETRY_MS, useDashcam } from '@/composables/useDashcam';
|
|
import { cancelDashcamCapture, readDashcam } from '@/journey/dashcam-feed';
|
|
import type { JourneySnapshot } from '@/journey/types';
|
|
import { validateProductionInputs } from '@/content/schemas';
|
|
import { render } from '@/entry-server';
|
|
import type { MotionPolicy } from '@/journey/motion-policy';
|
|
|
|
const fixture = (name: string): unknown =>
|
|
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
|
|
|
const policy: MotionPolicy = {
|
|
allowSceneRequest: true,
|
|
allowAutomaticVideo: true,
|
|
maximumTier: 'high',
|
|
reason: 'full',
|
|
};
|
|
|
|
describe('dashcam server HTML', () => {
|
|
it('renders nothing of the dashcam on the server', async () => {
|
|
const html = await render('/', validateProductionInputs(
|
|
fixture('portfolio-content.valid.json'),
|
|
fixture('asset-manifest.valid.json'),
|
|
fixture('site-config.preview.json'),
|
|
new URL('file:///prepared-assets/'),
|
|
));
|
|
expect(html).not.toMatch(/dashcam/iu);
|
|
});
|
|
});
|
|
|
|
describe('dashcam button', () => {
|
|
const mountControls = (dashcam?: boolean) => mount(DriveControls, {
|
|
props: {
|
|
policy,
|
|
maximumQuality: 'high',
|
|
sceneStatus: 'running',
|
|
audioState: { kind: 'idle' },
|
|
...(dashcam === undefined ? {} : { dashcam }),
|
|
},
|
|
});
|
|
|
|
it('is absent unless the shell offers it', () => {
|
|
expect(mountControls().find('.drive-controls__dashcam').exists()).toBe(false);
|
|
expect(mountControls(false).find('.drive-controls__dashcam').exists()).toBe(false);
|
|
});
|
|
|
|
it('opens on click and prefetches the frame on hover and focus', async () => {
|
|
const wrapper = mountControls(true);
|
|
const button = wrapper.get('.drive-controls__dashcam');
|
|
expect(button.attributes('aria-label')).toBe('Dashcam');
|
|
expect(button.attributes('aria-keyshortcuts')).toBe('c');
|
|
expect(button.find('svg').exists()).toBe(true);
|
|
await button.trigger('pointerenter');
|
|
await button.trigger('focus');
|
|
await button.trigger('click');
|
|
expect(wrapper.emitted('dashcamPrefetch')).toHaveLength(2);
|
|
expect(wrapper.emitted('dashcam')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('useDashcam', () => {
|
|
let stage: HTMLElement;
|
|
const allowed = ref(true);
|
|
const running = ref(true);
|
|
/** Stands in for DashcamFrame: announces its mount like the real one. */
|
|
const FrameStub = defineComponent({
|
|
emits: ['ready', 'close'],
|
|
setup(_props, { emit }) {
|
|
onMounted(() => emit('ready'));
|
|
return () => h('div', { class: 'dashcam-frame' }, [h('button', { class: 'dashcam-close', onClick: () => emit('close') }, 'Close')]);
|
|
},
|
|
});
|
|
let load: ReturnType<typeof vi.fn<() => Promise<{ default: Component }>>>;
|
|
|
|
const host = () => mount(defineComponent({
|
|
setup() {
|
|
const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true, load });
|
|
return () => h('div', [
|
|
h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show }),
|
|
dashcam.engaged.value && dashcam.frame.value
|
|
? h(dashcam.frame.value, { onReady: dashcam.ready, onClose: dashcam.close })
|
|
: null,
|
|
]);
|
|
},
|
|
}), { attachTo: document.body });
|
|
|
|
const press = (key: string, init: KeyboardEventInit = {}): KeyboardEvent => {
|
|
const event = new KeyboardEvent('keydown', { key, cancelable: true, bubbles: true, ...init });
|
|
window.dispatchEvent(event);
|
|
return event;
|
|
};
|
|
/** Lets the import promise settle and the frame mount. */
|
|
const settle = async (): Promise<void> => {
|
|
for (let i = 0; i < 4; i++) await flushPromises();
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
allowed.value = true;
|
|
running.value = true;
|
|
load = vi.fn(() => Promise.resolve({ default: FrameStub as Component }));
|
|
stage = document.createElement('div');
|
|
stage.className = 'reading-stage';
|
|
stage.dataset.readingStage = 'live';
|
|
document.body.append(stage);
|
|
});
|
|
|
|
afterEach(() => {
|
|
stage.remove();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
delete document.documentElement.dataset.dashcam;
|
|
});
|
|
|
|
it('folds the page only once the frame has mounted, and closes on c', async () => {
|
|
const wrapper = host();
|
|
expect(press('c').defaultPrevented).toBe(true);
|
|
// The chunk is still in flight: nothing has changed on the page yet.
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
expect(readDashcam().open).toBe(false);
|
|
await settle();
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(true);
|
|
expect(document.documentElement.dataset.dashcam).toBe('on');
|
|
expect(readDashcam()).toMatchObject({ open: true, drift: true });
|
|
expect(press('C', { shiftKey: true }).defaultPrevented).toBe(false);
|
|
press('c');
|
|
await settle();
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
|
|
expect(document.documentElement.dataset.dashcam).toBe('rise');
|
|
expect(readDashcam().open).toBe(false);
|
|
vi.advanceTimersByTime(400);
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('closes on Escape and from the frame\'s own Close control', async () => {
|
|
const wrapper = host();
|
|
press('c');
|
|
await settle();
|
|
expect(press('Escape').defaultPrevented).toBe(true);
|
|
expect(readDashcam().open).toBe(false);
|
|
vi.advanceTimersByTime(400);
|
|
await wrapper.get('.drive-controls__dashcam').trigger('click');
|
|
await settle();
|
|
await wrapper.get('.dashcam-close').trigger('click');
|
|
await settle();
|
|
expect(readDashcam().open).toBe(false);
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('retries a failed frame chunk once, then opens', async () => {
|
|
load.mockRejectedValueOnce(new Error('chunk'));
|
|
const wrapper = host();
|
|
press('c');
|
|
await settle();
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
vi.advanceTimersByTime(DASHCAM_RETRY_MS);
|
|
await settle();
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
expect(document.documentElement.dataset.dashcam).toBe('on');
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('abandons the open with nothing changed when the chunk keeps failing, and can try again', async () => {
|
|
load.mockRejectedValueOnce(new Error('chunk')).mockRejectedValueOnce(new Error('chunk'));
|
|
const wrapper = host();
|
|
press('c');
|
|
await settle();
|
|
vi.advanceTimersByTime(DASHCAM_RETRY_MS);
|
|
await settle();
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
expect(readDashcam().open).toBe(false);
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
|
|
// Not stuck: the next press loads again and opens.
|
|
press('c');
|
|
await settle();
|
|
expect(load).toHaveBeenCalledTimes(3);
|
|
expect(document.documentElement.dataset.dashcam).toBe('on');
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('a slow chunk can be cancelled, and a late arrival opens nothing', async () => {
|
|
let resolveLoad: (module: { default: Component }) => void = () => {};
|
|
load.mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve; }));
|
|
const wrapper = host();
|
|
press('c');
|
|
expect(press('Escape').defaultPrevented).toBe(true);
|
|
resolveLoad({ default: FrameStub as Component });
|
|
await settle();
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('gives up on a chunk that never arrives', async () => {
|
|
load.mockImplementation(() => new Promise(() => {}));
|
|
const wrapper = host();
|
|
press('c');
|
|
vi.advanceTimersByTime(DASHCAM_LOAD_TIMEOUT_MS);
|
|
await settle();
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
// c is a fresh request again, not a "close" of a phantom open.
|
|
expect(press('c').defaultPrevented).toBe(true);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('ignores c with modifiers or inside an editable field', () => {
|
|
const wrapper = host();
|
|
expect(press('c', { ctrlKey: true }).defaultPrevented).toBe(false);
|
|
const input = document.createElement('input');
|
|
document.body.append(input);
|
|
const event = new KeyboardEvent('keydown', { key: 'c', cancelable: true, bubbles: true });
|
|
input.dispatchEvent(event);
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
input.remove();
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it.each([
|
|
['motion is withheld', () => { allowed.value = false; }],
|
|
['the scene is not running', () => { running.value = false; }],
|
|
['the reading stage is not live', () => { stage.dataset.readingStage = 'still'; }],
|
|
])('does nothing when %s', async (_label, arrange) => {
|
|
const wrapper = host();
|
|
arrange();
|
|
expect(press('c').defaultPrevented).toBe(false);
|
|
await settle();
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
expect(readDashcam().open).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('does nothing on a phone-width viewport, where the button is hidden too', async () => {
|
|
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList));
|
|
const wrapper = host();
|
|
expect(press('c').defaultPrevented).toBe(false);
|
|
await settle();
|
|
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
|
expect(readDashcam().open).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it.each([
|
|
['motion is withdrawn', () => { allowed.value = false; }],
|
|
['the scene drops back to loading', () => { running.value = false; }],
|
|
])('closes when %s while open', async (_label, arrange) => {
|
|
const wrapper = host();
|
|
press('c');
|
|
await settle();
|
|
expect(readDashcam().open).toBe(true);
|
|
arrange();
|
|
await settle();
|
|
expect(readDashcam().open).toBe(false);
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('closes when the window is resized to phone width while open', async () => {
|
|
let narrow = false;
|
|
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: narrow && query === '(max-width: 767px)' }) as MediaQueryList));
|
|
const wrapper = host();
|
|
press('c');
|
|
await settle();
|
|
expect(readDashcam().open).toBe(true);
|
|
narrow = true;
|
|
window.dispatchEvent(new Event('resize'));
|
|
await settle();
|
|
expect(readDashcam().open).toBe(false);
|
|
expect(document.documentElement.dataset.dashcam).toBe('rise');
|
|
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe('DashcamFrame', () => {
|
|
const journey = {
|
|
activeSectionId: 'infrastructure', chapter: 'pre-dawn', routeProgress: 0.47,
|
|
sectionActivation: { activeHeadingOffsetVh: -40, nextHeadingOffsetVh: 120 },
|
|
} as unknown as JourneySnapshot;
|
|
const mountFrame = (sceneReady = true) => mount(DashcamFrame, {
|
|
props: { journey, sectionLabel: 'Infrastructure', name: 'Luca Pîslaru', city: 'Bucharest', sceneReady },
|
|
attachTo: document.body,
|
|
});
|
|
|
|
beforeEach(() => { vi.useFakeTimers(); });
|
|
afterEach(() => {
|
|
cancelDashcamCapture();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('announces its mount and always renders a Close control', async () => {
|
|
const wrapper = mountFrame(false);
|
|
expect(wrapper.emitted('ready')).toHaveLength(1);
|
|
const close = wrapper.get('.dashcam-frame__actions button:last-child');
|
|
expect(close.text()).toMatch(/close/iu);
|
|
expect(close.attributes('disabled')).toBeUndefined();
|
|
expect(document.activeElement).toBe(close.element);
|
|
await close.trigger('click');
|
|
expect(wrapper.emitted('close')).toHaveLength(1);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('recovers from a capture the scene never answers', async () => {
|
|
const wrapper = mountFrame();
|
|
const save = wrapper.get('.dashcam-frame__actions button:first-child');
|
|
await save.trigger('click');
|
|
expect(save.attributes('disabled')).toBeDefined();
|
|
vi.advanceTimersByTime(1_200);
|
|
await flushPromises();
|
|
expect(save.attributes('disabled')).toBeUndefined();
|
|
expect(wrapper.attributes('data-unavailable')).toBeUndefined();
|
|
expect(wrapper.text()).toMatch(/try again/iu);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('takes no postcard from a click on the empty frame', async () => {
|
|
const wrapper = mountFrame();
|
|
await wrapper.trigger('click');
|
|
expect(wrapper.get('.dashcam-frame__actions button:first-child').attributes('disabled')).toBeUndefined();
|
|
expect(document.documentElement.dataset.dashcamShutter).toBeUndefined();
|
|
wrapper.unmount();
|
|
});
|
|
});
|