feat: dashcam photo mode with telemetry frame, lens drift and postcard capture
This commit is contained in:
169
tests/unit/dashcam-controls.test.ts
Normal file
169
tests/unit/dashcam-controls.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import DriveControls from '@/components/DriveControls.vue';
|
||||
import { useDashcam } from '@/composables/useDashcam';
|
||||
import { readDashcam } from '@/journey/dashcam-feed';
|
||||
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);
|
||||
|
||||
const host = () => mount(defineComponent({
|
||||
setup() {
|
||||
const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true });
|
||||
return () => h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show });
|
||||
},
|
||||
}), { 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;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
allowed.value = true;
|
||||
running.value = true;
|
||||
stage = document.createElement('div');
|
||||
stage.className = 'reading-stage';
|
||||
stage.dataset.readingStage = 'live';
|
||||
document.body.append(stage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stage.remove();
|
||||
vi.useRealTimers();
|
||||
delete document.documentElement.dataset.dashcam;
|
||||
});
|
||||
|
||||
it('opens and closes on c, then lifts the rise state after 400 ms', () => {
|
||||
const wrapper = host();
|
||||
expect(press('c').defaultPrevented).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');
|
||||
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', () => {
|
||||
const wrapper = host();
|
||||
press('c');
|
||||
expect(press('Escape').defaultPrevented).toBe(true);
|
||||
expect(readDashcam().open).toBe(false);
|
||||
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', (_label, arrange) => {
|
||||
const wrapper = host();
|
||||
arrange();
|
||||
expect(press('c').defaultPrevented).toBe(false);
|
||||
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', () => {
|
||||
const matchMedia = vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList);
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
try {
|
||||
const wrapper = host();
|
||||
expect(press('c').defaultPrevented).toBe(false);
|
||||
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
||||
expect(readDashcam().open).toBe(false);
|
||||
wrapper.unmount();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when motion is withdrawn while open', async () => {
|
||||
const wrapper = host();
|
||||
press('c');
|
||||
allowed.value = false;
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readDashcam().open).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
197
tests/unit/dashcam-feed.test.ts
Normal file
197
tests/unit/dashcam-feed.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
cancelDashcamCapture,
|
||||
readDashcam,
|
||||
requestDashcamCapture,
|
||||
setDashcamOpen,
|
||||
setDashcamPointer,
|
||||
takeDashcamCapture,
|
||||
} from '@/journey/dashcam-feed';
|
||||
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
|
||||
import {
|
||||
chapterLabel,
|
||||
dashcamClock,
|
||||
dashcamTitle,
|
||||
frameBottomLeft,
|
||||
frameTopLeft,
|
||||
postcardCredit,
|
||||
postcardFilename,
|
||||
routePercent,
|
||||
type DashcamTelemetry,
|
||||
} from '@/lib/dashcam-postcard';
|
||||
import { attachMusicPulse, createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse';
|
||||
import { playShutterClick } from '@/audio/switchboard-click';
|
||||
|
||||
afterEach(() => {
|
||||
setDashcamOpen(false, false);
|
||||
setMusicPulseAudible(false);
|
||||
attachMusicPulse(null);
|
||||
});
|
||||
|
||||
describe('dashcam feed', () => {
|
||||
it('opens with drift only when asked, and resets the pointer on every change', () => {
|
||||
setDashcamOpen(true, true);
|
||||
setDashcamPointer(0.5, -0.25, 100);
|
||||
expect(readDashcam()).toMatchObject({ open: true, drift: true, px: 0.5, py: -0.25, movedAt: 100 });
|
||||
setDashcamOpen(false, true);
|
||||
expect(readDashcam()).toMatchObject({ open: false, drift: false, px: 0, py: 0 });
|
||||
setDashcamOpen(true, false);
|
||||
expect(readDashcam().drift).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps the pointer to [-1, 1]', () => {
|
||||
setDashcamOpen(true, true);
|
||||
setDashcamPointer(4, -9, 0);
|
||||
expect(readDashcam()).toMatchObject({ px: 1, py: -1 });
|
||||
});
|
||||
|
||||
it('hands a capture to the scene exactly once', () => {
|
||||
const resolve = vi.fn();
|
||||
requestDashcamCapture(resolve);
|
||||
expect(takeDashcamCapture()).toBe(resolve);
|
||||
expect(takeDashcamCapture()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a queued capture on cancel and on close', () => {
|
||||
requestDashcamCapture(vi.fn());
|
||||
cancelDashcamCapture();
|
||||
expect(takeDashcamCapture()).toBeNull();
|
||||
setDashcamOpen(true, false);
|
||||
requestDashcamCapture(vi.fn());
|
||||
setDashcamOpen(false, false);
|
||||
expect(takeDashcamCapture()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashcam lens', () => {
|
||||
const feed = (overrides: Partial<ReturnType<typeof readDashcam>> = {}) => ({
|
||||
open: true, drift: true, px: 1, py: 1, movedAt: 0, ...overrides,
|
||||
});
|
||||
|
||||
it('targets the pointer while open with drift, centre otherwise', () => {
|
||||
expect(driftTarget(feed({ px: 0.4, py: -0.6 }), 10)).toEqual({ x: 0.4, y: -0.6 });
|
||||
expect(driftTarget(feed({ px: 7, py: -7 }), 10)).toEqual({ x: 1, y: -1 });
|
||||
expect(driftTarget(feed({ open: false }), 10)).toEqual({ x: 0, y: 0 });
|
||||
expect(driftTarget(feed({ drift: false }), 10)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('returns to centre after 3 s without pointer movement', () => {
|
||||
expect(driftTarget(feed(), DASHCAM_LENS.idleMs)).toEqual({ x: 1, y: 1 });
|
||||
expect(driftTarget(feed(), DASHCAM_LENS.idleMs + 1)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('eases the drift and never exceeds +-1.6 x and +-0.5 target height', () => {
|
||||
const lens = createDashcamLens();
|
||||
const first = { ...lens.update(feed(), 1 / 60, 0) };
|
||||
expect(first.x).toBeGreaterThan(0);
|
||||
expect(first.x).toBeLessThan(DASHCAM_LENS.driftX * 0.1);
|
||||
let state = first;
|
||||
for (let i = 0; i < 600; i += 1) state = lens.update(feed(), 1 / 60, 0);
|
||||
expect(state.x).toBeCloseTo(1.6, 3);
|
||||
expect(state.targetY).toBeCloseTo(-0.5, 3);
|
||||
for (let i = 0; i < 600; i += 1) state = lens.update(feed({ px: -1, py: -1 }), 1 / 60, 0);
|
||||
expect(state.x).toBeGreaterThanOrEqual(-1.6);
|
||||
expect(state.targetY).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
|
||||
it('closes the letterbox to 0.16 over 400 ms and opens it again', () => {
|
||||
const lens = createDashcamLens();
|
||||
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
|
||||
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.16, 5);
|
||||
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
|
||||
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBe(0);
|
||||
});
|
||||
|
||||
it('snaps the bars and holds centre without drift (touch, low tier)', () => {
|
||||
const lens = createDashcamLens();
|
||||
const open = lens.update(feed({ drift: false }), 1 / 60, 0);
|
||||
expect(Math.abs(open.x) + Math.abs(open.targetY)).toBe(0);
|
||||
expect(open.letterbox).toBeCloseTo(0.16, 10);
|
||||
expect(lens.update(feed({ open: false, drift: false }), 1 / 60, 0).letterbox).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashcam telemetry', () => {
|
||||
const telemetry: DashcamTelemetry = {
|
||||
sectionId: 'infrastructure',
|
||||
sectionLabel: 'Infrastructure',
|
||||
chapter: 'pre-dawn',
|
||||
exit: '04',
|
||||
routePercent: 47,
|
||||
clock: '03:51',
|
||||
name: 'Luca Pîslaru',
|
||||
city: 'Bucharest',
|
||||
};
|
||||
|
||||
it('formats the document title', () => {
|
||||
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 47% · INFRASTRUCTURE · 03:51');
|
||||
});
|
||||
|
||||
it('formats the frame plates and the credit', () => {
|
||||
expect(frameTopLeft(telemetry)).toBe('DASHCAM · PRE DAWN · EXIT 04');
|
||||
expect(frameBottomLeft(telemetry)).toBe('ROUTE 47% · INFRASTRUCTURE');
|
||||
expect(postcardCredit(telemetry.name, telemetry.city)).toBe('NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST');
|
||||
expect(chapterLabel('selected-builds')).toBe('SELECTED BUILDS');
|
||||
});
|
||||
|
||||
it('rounds and clamps route progress', () => {
|
||||
expect(routePercent(0.474)).toBe(47);
|
||||
expect(routePercent(1.4)).toBe(100);
|
||||
expect(routePercent(-1)).toBe(0);
|
||||
expect(routePercent(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('names the postcard after the section and the clock', () => {
|
||||
expect(postcardFilename('infrastructure', '03:51')).toBe('night-shift-infrastructure-0351.png');
|
||||
expect(postcardFilename('selected-builds', '23:05')).toBe('night-shift-selected-builds-2305.png');
|
||||
expect(postcardFilename('', '7:5')).toBe('night-shift-route-0075.png');
|
||||
});
|
||||
|
||||
it('uses the live Bucharest clock outside the maintenance window', () => {
|
||||
const now = new Date('2026-01-15T01:51:00Z');
|
||||
expect(dashcamClock('profile', null, now)).toBe('03:51');
|
||||
expect(dashcamClock('infrastructure', null, now)).toBe('03:51');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchboard click', () => {
|
||||
it('stays silent and creates nothing without an audible soundtrack', () => {
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
expect(playShutterClick()).toBe(false);
|
||||
});
|
||||
|
||||
it('plays into the mix bus at the switchboard gain when audible', () => {
|
||||
const gains: { gain: { setValueAtTime: ReturnType<typeof vi.fn>; linearRampToValueAtTime: ReturnType<typeof vi.fn>; exponentialRampToValueAtTime: ReturnType<typeof vi.fn> }; connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
||||
const context = {
|
||||
state: 'running',
|
||||
sampleRate: 1000,
|
||||
currentTime: 2,
|
||||
createGain: () => {
|
||||
const node = {
|
||||
gain: { value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
context: undefined as unknown,
|
||||
};
|
||||
node.context = context;
|
||||
gains.push(node);
|
||||
return node;
|
||||
},
|
||||
createAnalyser: () => { throw new Error('no analyser'); },
|
||||
createBuffer: (_c: number, length: number) => ({ getChannelData: () => new Float32Array(length) }),
|
||||
createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null }),
|
||||
};
|
||||
const destination = {} as AudioNode;
|
||||
createPulseTap(context as unknown as BaseAudioContext, destination);
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
setMusicPulseAudible(true);
|
||||
const bus = readSwitchboard();
|
||||
expect(bus).toBe(gains[0]);
|
||||
expect(playShutterClick()).toBe(true);
|
||||
const envelope = gains[1]!;
|
||||
expect(envelope.connect).toHaveBeenCalledWith(bus);
|
||||
expect(envelope.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.18, expect.any(Number));
|
||||
attachMusicPulse(null);
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
});
|
||||
});
|
||||
127
tests/unit/dashcam-postcard.test.ts
Normal file
127
tests/unit/dashcam-postcard.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DASHCAM_COORDINATES,
|
||||
POSTCARD_HEIGHT,
|
||||
POSTCARD_WIDTH,
|
||||
coverCrop,
|
||||
drawPostcard,
|
||||
postcardLayout,
|
||||
type DashcamTelemetry,
|
||||
type PostcardContext,
|
||||
} from '@/lib/dashcam-postcard';
|
||||
|
||||
const telemetry: DashcamTelemetry = {
|
||||
sectionId: 'infrastructure',
|
||||
sectionLabel: 'Infrastructure',
|
||||
chapter: 'pre-dawn',
|
||||
exit: '04',
|
||||
routePercent: 47,
|
||||
clock: '03:51',
|
||||
name: 'Luca Pîslaru',
|
||||
city: 'Bucharest',
|
||||
};
|
||||
|
||||
interface TextCall { text: string; x: number; y: number; align: CanvasTextAlign; font: string }
|
||||
|
||||
function stubContext() {
|
||||
const texts: TextCall[] = [];
|
||||
const images: number[][] = [];
|
||||
const ctx = {
|
||||
fillStyle: '' as unknown,
|
||||
strokeStyle: '' as unknown,
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
textAlign: 'start' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
||||
drawImage: vi.fn((_source: unknown, ...rest: number[]) => { images.push(rest); }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn((text: string, x: number, y: number) => {
|
||||
texts.push({ text, x, y, align: ctx.textAlign, font: ctx.font });
|
||||
}),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
};
|
||||
return { ctx: ctx as unknown as PostcardContext & typeof ctx, texts, images };
|
||||
}
|
||||
|
||||
const source = (width: number, height: number) => ({ width, height }) as unknown as HTMLCanvasElement;
|
||||
|
||||
describe('coverCrop', () => {
|
||||
it('crops a wider source to the 16:9 centre', () => {
|
||||
expect(coverCrop(2400, 900, 1600, 900)).toEqual({ sx: 400, sy: 0, sw: 1600, sh: 900 });
|
||||
});
|
||||
|
||||
it('crops a taller source to the 16:9 centre', () => {
|
||||
const crop = coverCrop(1440, 1080, 1600, 900);
|
||||
expect(crop.sx).toBe(0);
|
||||
expect(crop.sw).toBe(1440);
|
||||
expect(crop.sh).toBeCloseTo(810, 6);
|
||||
expect(crop.sy).toBeCloseTo(135, 6);
|
||||
});
|
||||
|
||||
it('keeps an exact-ratio source whole and tolerates an empty one', () => {
|
||||
expect(coverCrop(3200, 1800, 1600, 900)).toEqual({ sx: 0, sy: 0, sw: 3200, sh: 1800 });
|
||||
expect(coverCrop(0, 0, 1600, 900)).toEqual({ sx: 0, sy: 0, sw: 0, sh: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('postcardLayout', () => {
|
||||
it('keeps every plate inside the frame at 1600 x 900', () => {
|
||||
const l = postcardLayout();
|
||||
expect(l.inset).toBe(44);
|
||||
expect(l.left).toBeGreaterThan(l.inset);
|
||||
expect(l.right).toBeLessThan(POSTCARD_WIDTH - l.inset);
|
||||
expect(l.top - l.font).toBeGreaterThan(l.inset);
|
||||
expect(l.bottom - l.lineGap - l.font).toBeGreaterThan(POSTCARD_HEIGHT / 2);
|
||||
expect(l.bottom).toBeLessThan(POSTCARD_HEIGHT - l.inset);
|
||||
expect(l.credit).toBeGreaterThan(l.bottom);
|
||||
expect(l.credit).toBeLessThan(POSTCARD_HEIGHT);
|
||||
});
|
||||
|
||||
it('scales with the smaller axis', () => {
|
||||
expect(postcardLayout(800, 450).inset).toBe(22);
|
||||
expect(postcardLayout(800, 900).inset).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawPostcard', () => {
|
||||
it('covers the frame with the scene and writes the telemetry and credit', () => {
|
||||
const { ctx, texts, images } = stubContext();
|
||||
drawPostcard(ctx, source(2880, 1800), telemetry);
|
||||
const l = postcardLayout();
|
||||
|
||||
expect(images).toEqual([[0, 90, 2880, 1620, 0, 0, POSTCARD_WIDTH, POSTCARD_HEIGHT]]);
|
||||
expect(texts.map((call) => call.text)).toEqual([
|
||||
'REC DASHCAM · PRE DAWN · EXIT 04',
|
||||
'ROUTE 47% · INFRASTRUCTURE',
|
||||
'03:51',
|
||||
'LUCA PÎSLARU',
|
||||
DASHCAM_COORDINATES,
|
||||
'NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST',
|
||||
]);
|
||||
const byText = Object.fromEntries(texts.map((call) => [call.text, call]));
|
||||
expect(byText['ROUTE 47% · INFRASTRUCTURE']).toMatchObject({ x: l.left, y: l.bottom, align: 'left' });
|
||||
expect(byText['03:51']).toMatchObject({ x: l.right, y: l.top, align: 'right' });
|
||||
expect(byText[DASHCAM_COORDINATES]).toMatchObject({ x: l.right, y: l.bottom - l.lineGap, align: 'right' });
|
||||
expect(byText['NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST']).toMatchObject({ x: 800, y: l.credit, align: 'center' });
|
||||
expect(texts.every((call) => call.font.includes('Geist Mono'))).toBe(true);
|
||||
// Four corner brackets.
|
||||
expect(ctx.stroke).toHaveBeenCalledTimes(4);
|
||||
expect(ctx.save).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.restore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still draws the chrome over an empty source', () => {
|
||||
const { ctx, texts, images } = stubContext();
|
||||
drawPostcard(ctx, source(0, 0), telemetry);
|
||||
expect(images).toEqual([]);
|
||||
expect(texts).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user