Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
222 lines
7.6 KiB
TypeScript
222 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { createSignController } from '@/journey/sign-controller';
|
|
import type { SignControllerSample } from '@/journey/types';
|
|
|
|
const sample = (
|
|
overrides: Partial<SignControllerSample> = {},
|
|
): SignControllerSample => ({
|
|
now: 0,
|
|
source: 'passive-scroll',
|
|
direction: 1,
|
|
acceleration: 0,
|
|
deltaY: 16,
|
|
viewportHeight: 800,
|
|
crossedBoundaries: 0,
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 1,
|
|
activeSectionId: 'start',
|
|
motionAllowed: true,
|
|
...overrides,
|
|
});
|
|
|
|
describe('clock-driven sign lifecycle', () => {
|
|
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
|
|
const controller = createSignController();
|
|
|
|
expect(controller.sample(sample())).toEqual({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
progress: 0,
|
|
opacity: 0,
|
|
});
|
|
expect(controller.needsFrame()).toBe(true);
|
|
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
|
|
phase: 'anticipate',
|
|
progress: expect.closeTo(299 / 300, 10),
|
|
});
|
|
expect(controller.sample(sample({ now: 300, direction: 0 }))).toMatchObject({
|
|
phase: 'approach',
|
|
progress: 0,
|
|
});
|
|
expect(controller.sample(sample({ now: 1_200, direction: 0 }))).toMatchObject({
|
|
phase: 'dwell',
|
|
progress: 1,
|
|
opacity: 1,
|
|
});
|
|
expect(controller.sample(sample({ now: 2_599, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
|
|
phase: 'dwell',
|
|
});
|
|
expect(controller.sample(sample({ now: 2_600, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0,
|
|
opacity: 1,
|
|
});
|
|
expect(controller.sample(sample({ now: 3_299, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: expect.closeTo(699 / 700, 10),
|
|
});
|
|
expect(controller.sample(sample({ now: 3_300, direction: 0, activeSectionId: 'profile' }))).toBeNull();
|
|
expect(controller.needsFrame()).toBe(false);
|
|
expect(controller.sample(sample({
|
|
now: 3_649,
|
|
candidateId: 'experience',
|
|
activeSectionId: 'profile',
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 3_650,
|
|
candidateId: 'experience',
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({ targetId: 'experience', phase: 'anticipate' });
|
|
});
|
|
|
|
it('shortens approach continuously to 650ms at full acceleration', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample({ acceleration: 1 }));
|
|
|
|
expect(controller.sample(sample({ now: 300, direction: 0 }))).toMatchObject({
|
|
phase: 'approach',
|
|
progress: 0,
|
|
});
|
|
expect(controller.sample(sample({ now: 949, direction: 0 }))).toMatchObject({
|
|
phase: 'approach',
|
|
progress: expect.closeTo(649 / 650, 10),
|
|
});
|
|
expect(controller.sample(sample({ now: 950, direction: 0 }))).toMatchObject({
|
|
phase: 'dwell',
|
|
progress: 1,
|
|
});
|
|
});
|
|
|
|
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0 }));
|
|
|
|
expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
|
|
expect(controller.needsFrame()).toBe(false);
|
|
expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0,
|
|
});
|
|
expect(controller.needsFrame()).toBe(true);
|
|
});
|
|
|
|
it('cancels an unreadable sign on reverse and departs a readable sign cleanly', () => {
|
|
const early = createSignController();
|
|
early.sample(sample());
|
|
expect(early.sample(sample({ now: 500, direction: -1, deltaY: -20 }))).toBeNull();
|
|
expect(early.needsFrame()).toBe(false);
|
|
|
|
const readable = createSignController();
|
|
readable.sample(sample());
|
|
readable.sample(sample({ now: 1_200, direction: 0 }));
|
|
expect(readable.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['explicit navigation', { source: 'explicit-navigation' as const }],
|
|
['history restoration', { source: 'history-restoration' as const }],
|
|
['initial positioning', { source: 'initial' as const }],
|
|
['disabled motion', { motionAllowed: false }],
|
|
])('suppresses or clears concealed signs for %s', (_label, overrides) => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
|
|
expect(controller.sample(sample({ now: 200, ...overrides }))).toBeNull();
|
|
expect(controller.needsFrame()).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['multiple crossed sections', { crossedBoundaries: 2 }],
|
|
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
|
])('departs a readable stale sign cleanly after %s', (_label, overrides) => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0 }));
|
|
|
|
expect(controller.sample(sample({ now: 1_400, ...overrides }))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'depart',
|
|
progress: 0,
|
|
opacity: 1,
|
|
});
|
|
expect(controller.needsFrame()).toBe(true);
|
|
});
|
|
|
|
it('departs when the authoritative section has already passed the latched target', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
|
|
expect(controller.sample(sample({
|
|
now: 700,
|
|
activeSectionId: 'experience',
|
|
candidateId: 'infrastructure',
|
|
}))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'depart',
|
|
progress: 0,
|
|
});
|
|
});
|
|
|
|
it('retargets a changed far-away candidate only while the sign is concealed', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
|
|
expect(controller.sample(sample({
|
|
now: 100,
|
|
candidateId: 'experience',
|
|
}))).toMatchObject({
|
|
targetId: 'experience',
|
|
phase: 'anticipate',
|
|
progress: 0,
|
|
opacity: 0,
|
|
});
|
|
});
|
|
|
|
it('does not impose cooldown when an idle initial sample precedes real passive input', () => {
|
|
const controller = createSignController();
|
|
|
|
expect(controller.sample(sample({ source: 'initial' }))).toBeNull();
|
|
expect(controller.sample(sample({ now: 100 }))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['reverse input', { direction: -1 as const, deltaY: -1 }],
|
|
['stationary input', { direction: 0 as const, deltaY: 0 }],
|
|
['candidate before the approach window', { candidateDistanceVh: 1.200_001 }],
|
|
['candidate already passed', { candidateDistanceVh: 0 }],
|
|
['candidate is already active', { activeSectionId: 'profile' as const }],
|
|
['missing candidate', { candidateId: null }],
|
|
])('does not latch for %s', (_label, overrides) => {
|
|
const controller = createSignController();
|
|
expect(controller.sample(sample(overrides))).toBeNull();
|
|
});
|
|
|
|
it('keeps one target latched when the next candidate changes', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
|
|
expect(controller.sample(sample({
|
|
now: 600,
|
|
direction: 0,
|
|
candidateId: 'experience',
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'approach' });
|
|
});
|
|
|
|
it('resets malformed samples to a finite idle state', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
|
|
expect(controller.sample(sample({ now: Number.NaN }))).toBeNull();
|
|
expect(controller.needsFrame()).toBe(false);
|
|
controller.reset();
|
|
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
|
|
});
|
|
});
|