fix: polish pass for traffic, nav, icons, contact email reveal and dashcam robustness
This commit is contained in:
@@ -187,6 +187,35 @@ describe("SceneCanvas", () => {
|
||||
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: {
|
||||
|
||||
@@ -204,8 +204,8 @@ describe('billboard world', () => {
|
||||
expect(full.inspect().flickers).toBeLessThanOrEqual(Math.ceil(3 / BILLBOARD_TIMING.flickerGap));
|
||||
expect(full.inspect().shimmers).toBe(1);
|
||||
expect(full.inspect().zoom).toBe(1);
|
||||
// Lite: one steady flood, no pulse, flicker, shimmer or hover zoom.
|
||||
expect(lite.inspect()).toMatchObject({ floods: [1, 0], flickers: 0, shimmers: 0, zoom: 0 });
|
||||
// Lite: both floods steady (the whole face lit), no pulse, flicker, shimmer or hover zoom.
|
||||
expect(lite.inspect()).toMatchObject({ floods: [1, 1], flickers: 0, shimmers: 0, zoom: 0 });
|
||||
});
|
||||
|
||||
it('never throws into the render loop', () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { defineComponent, h, onMounted, ref, type Component } from 'vue';
|
||||
import DashcamFrame from '@/components/DashcamFrame.vue';
|
||||
import DriveControls from '@/components/DriveControls.vue';
|
||||
import { useDashcam } from '@/composables/useDashcam';
|
||||
import { readDashcam } from '@/journey/dashcam-feed';
|
||||
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';
|
||||
@@ -66,11 +68,25 @@ 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 });
|
||||
return () => h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show });
|
||||
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 });
|
||||
|
||||
@@ -79,11 +95,16 @@ describe('useDashcam', () => {
|
||||
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';
|
||||
@@ -93,16 +114,24 @@ describe('useDashcam', () => {
|
||||
afterEach(() => {
|
||||
stage.remove();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
delete document.documentElement.dataset.dashcam;
|
||||
});
|
||||
|
||||
it('opens and closes on c, then lifts the rise state after 400 ms', () => {
|
||||
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);
|
||||
@@ -110,11 +139,75 @@ describe('useDashcam', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
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);
|
||||
expect(readDashcam().open).toBe(false);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -135,35 +228,104 @@ describe('useDashcam', () => {
|
||||
['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) => {
|
||||
])('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', () => {
|
||||
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 () => {
|
||||
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();
|
||||
press('c');
|
||||
allowed.value = false;
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '@/journey/dashcam-feed';
|
||||
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
|
||||
import {
|
||||
chapterLabel,
|
||||
dashcamClock,
|
||||
dashcamTitle,
|
||||
frameBottomLeft,
|
||||
@@ -117,25 +116,25 @@ describe('dashcam telemetry', () => {
|
||||
sectionLabel: 'Infrastructure',
|
||||
chapter: 'pre-dawn',
|
||||
exit: '04',
|
||||
routePercent: 47,
|
||||
routePercent: 45,
|
||||
clock: '03:51',
|
||||
name: 'Luca Pîslaru',
|
||||
city: 'Bucharest',
|
||||
};
|
||||
|
||||
it('formats the document title', () => {
|
||||
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 47% · INFRASTRUCTURE · 03:51');
|
||||
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 45% · 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(frameTopLeft(telemetry)).toBe('DASHCAM · EXIT 04');
|
||||
expect(frameBottomLeft(telemetry)).toBe('ROUTE 45% · 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);
|
||||
it('rounds and clamps route progress on the tab title\'s 5% step', () => {
|
||||
expect(routePercent(0.474)).toBe(45);
|
||||
expect(routePercent(0.476)).toBe(50);
|
||||
expect(routePercent(1.4)).toBe(100);
|
||||
expect(routePercent(-1)).toBe(0);
|
||||
expect(routePercent(Number.NaN)).toBe(0);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
coverCrop,
|
||||
drawPostcard,
|
||||
postcardLayout,
|
||||
postcardSize,
|
||||
type DashcamTelemetry,
|
||||
type PostcardContext,
|
||||
} from '@/lib/dashcam-postcard';
|
||||
@@ -15,7 +16,7 @@ const telemetry: DashcamTelemetry = {
|
||||
sectionLabel: 'Infrastructure',
|
||||
chapter: 'pre-dawn',
|
||||
exit: '04',
|
||||
routePercent: 47,
|
||||
routePercent: 45,
|
||||
clock: '03:51',
|
||||
name: 'Luca Pîslaru',
|
||||
city: 'Bucharest',
|
||||
@@ -53,6 +54,19 @@ function stubContext() {
|
||||
|
||||
const source = (width: number, height: number) => ({ width, height }) as unknown as HTMLCanvasElement;
|
||||
|
||||
describe('postcardSize', () => {
|
||||
it('keeps the buffer\'s own pixels: a 16:9 crop, never upscaled', () => {
|
||||
expect(postcardSize(1440, 900)).toEqual({ w: 1440, h: 810 });
|
||||
expect(postcardSize(1920, 1080)).toEqual({ w: 1920, h: 1080 });
|
||||
expect(postcardSize(1024, 1366)).toEqual({ w: 1024, h: 576 });
|
||||
});
|
||||
|
||||
it('downsamples only past the cap, and survives an empty buffer', () => {
|
||||
expect(postcardSize(2880, 1800)).toEqual({ w: 2560, h: 1440 });
|
||||
expect(postcardSize(0, 0)).toEqual({ w: POSTCARD_WIDTH, h: POSTCARD_HEIGHT });
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -99,15 +113,15 @@ describe('drawPostcard', () => {
|
||||
|
||||
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',
|
||||
'REC DASHCAM · EXIT 04',
|
||||
'ROUTE 45% · 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['ROUTE 45% · 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' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import {
|
||||
MILESTONE_FLASH_MAX_RATE,
|
||||
createMilestones,
|
||||
MILESTONE_FAR_Z,
|
||||
MILESTONE_NEAR_Z,
|
||||
@@ -100,6 +101,24 @@ describe('mile marker mesh', () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('does not flash posts swept past by a navigator jump or a fling', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const markers = createMilestones(resources, true);
|
||||
// A navigator long-scroll crosses the whole chapter in about 0.6 s.
|
||||
let peak = 0;
|
||||
for (let frame = 0; frame <= 36; frame += 1) {
|
||||
markers.update('experience', frame / 36, 0, 1 / 60);
|
||||
peak = Math.max(peak, ...markers.flashes());
|
||||
}
|
||||
expect(peak).toBe(0);
|
||||
// Parked, then a single wheel tick at reading pace still flashes the post it crosses.
|
||||
for (let i = 0; i < 90; i += 1) markers.update('experience', passPoint(3, 4) + 0.01, 0, 1 / 60);
|
||||
markers.update('experience', passPoint(3, 4) - 0.01, 0, 1 / 60);
|
||||
expect(markers.flashes()[3]).toBe(1);
|
||||
expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('never flashes on the low tier or touch', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const markers = createMilestones(resources, false);
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HERO_CAR_HALF_WIDTH,
|
||||
TRAFFIC_BODY,
|
||||
TRAFFIC_FADE_LENGTH,
|
||||
TRAFFIC_LAMP_HALF_SPACING,
|
||||
TRAFFIC_LANE_JITTER,
|
||||
TRAFFIC_LANES,
|
||||
TRAFFIC_NEAR_Z,
|
||||
trafficLamps,
|
||||
trafficNearFade,
|
||||
wrapTrafficZ,
|
||||
} from '@/scene/world/traffic';
|
||||
|
||||
describe('expressway traffic stays clear of the hero car', () => {
|
||||
it('keeps every lamp outside the car body, with lane jitter', () => {
|
||||
it('keeps every body outside the hero car, with lane jitter', () => {
|
||||
for (const lane of TRAFFIC_LANES) {
|
||||
const half = lane < 0 ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow;
|
||||
const innerLamp = Math.abs(lane) - half - 0.25; // 0.25: max lane jitter
|
||||
expect(innerLamp).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
||||
const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER;
|
||||
expect(inner).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
||||
}
|
||||
});
|
||||
|
||||
it('never lets bodies in adjacent lanes overlap, and keeps them on the road', () => {
|
||||
const spans = TRAFFIC_LANES.map((lane) => {
|
||||
const half = TRAFFIC_BODY[lane.kind].halfWidth + TRAFFIC_LANE_JITTER;
|
||||
return [lane.x - half, lane.x + half] as const;
|
||||
}).sort((a, b) => a[0] - b[0]);
|
||||
for (let i = 1; i < spans.length; i++) expect(spans[i]![0]).toBeGreaterThan(spans[i - 1]![1]);
|
||||
for (const [left, right] of spans) {
|
||||
expect(left).toBeGreaterThan(-8);
|
||||
expect(right).toBeLessThan(8);
|
||||
}
|
||||
});
|
||||
|
||||
it('runs each lane at one speed, so no vehicle drives through another', () => {
|
||||
const xs = TRAFFIC_LANES.map((lane) => lane.x);
|
||||
expect(new Set(xs).size).toBe(xs.length);
|
||||
});
|
||||
|
||||
it('puts every lamp on the vehicle body', () => {
|
||||
for (const kind of ['car', 'truck'] as const) {
|
||||
const lamps = trafficLamps(kind, 0.5);
|
||||
expect(lamps.length).toBeGreaterThanOrEqual(3);
|
||||
for (const lamp of lamps) expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
|
||||
}
|
||||
// A readable pair: two tail lamps mirrored at one height.
|
||||
const [left, right] = trafficLamps('car', 0);
|
||||
expect(left!.x).toBe(-right!.x);
|
||||
expect(left!.y).toBe(right!.y);
|
||||
});
|
||||
|
||||
it('is fully dark before the car near field and lit beyond the fade', () => {
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0);
|
||||
expect(trafficNearFade(0)).toBe(0);
|
||||
|
||||
Reference in New Issue
Block a user