fix: make nightdrive signage reliable and legible
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSignController } from '@/journey/sign-controller';
|
||||
import { SIGN_ROUTE_ORDER, signRouteFor } from '@/journey/sign-routes';
|
||||
import type { SignControllerSample } from '@/journey/types';
|
||||
|
||||
const sample = (
|
||||
@@ -10,16 +11,30 @@ const sample = (
|
||||
direction: 1,
|
||||
acceleration: 0,
|
||||
deltaY: 16,
|
||||
viewportWidth: 1_440,
|
||||
viewportHeight: 800,
|
||||
crossedBoundaries: 0,
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 1,
|
||||
candidateDistanceVh: 0.5,
|
||||
activeSectionId: 'start',
|
||||
motionAllowed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('clock-driven sign lifecycle', () => {
|
||||
it('defines one explicit route marker and safe corridor for every eligible destination', () => {
|
||||
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
||||
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
||||
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
@@ -28,6 +43,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
phase: 'anticipate',
|
||||
progress: 0,
|
||||
opacity: 0,
|
||||
viewportProfile: 'wide',
|
||||
});
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
|
||||
@@ -87,21 +103,34 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
|
||||
it('keeps the latched viewport profile immutable across a responsive resize', () => {
|
||||
const controller = createSignController();
|
||||
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 500,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
viewportWidth: 844,
|
||||
viewportHeight: 390,
|
||||
}))).toMatchObject({ targetId: 'profile', viewportProfile: 'wide' });
|
||||
});
|
||||
|
||||
it('departs after the minimum dwell even when scrolling stops before 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({
|
||||
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({ phase: 'dwell' });
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels a concealed sign on reverse and departs a visible approaching sign cleanly', () => {
|
||||
it('cancels a concealed sign on reverse but preserves the full dwell for a visible approach', () => {
|
||||
const early = createSignController();
|
||||
early.sample(sample());
|
||||
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
|
||||
@@ -109,10 +138,14 @@ describe('clock-driven sign lifecycle', () => {
|
||||
|
||||
const readable = createSignController();
|
||||
readable.sample(sample());
|
||||
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 })))
|
||||
.toMatchObject({ phase: 'approach' });
|
||||
expect(readable.sample(sample({ now: 1_200, direction: 0, deltaY: 0 })))
|
||||
.toMatchObject({ phase: 'dwell' });
|
||||
expect(readable.sample(sample({ now: 2_599, direction: 0, deltaY: 0 })))
|
||||
.toMatchObject({ phase: 'dwell' });
|
||||
expect(readable.sample(sample({ now: 2_600, direction: 0, deltaY: 0 })))
|
||||
.toMatchObject({ phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('preserves the minimum readable dwell when reverse input requests departure', () => {
|
||||
@@ -166,7 +199,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('departs when the authoritative section has already passed the latched target', () => {
|
||||
it('preserves approach and minimum dwell when the authoritative section passes the target', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
|
||||
@@ -176,9 +209,29 @@ describe('clock-driven sign lifecycle', () => {
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
phase: 'approach',
|
||||
});
|
||||
expect(controller.sample(sample({
|
||||
now: 1_200,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_599,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_600,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('does not truncate dwell when the authoritative section passes the target', () => {
|
||||
@@ -236,7 +289,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
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 before the approach window', { candidateDistanceVh: 0.550_001 }],
|
||||
['candidate already passed', { candidateDistanceVh: 0 }],
|
||||
['candidate is already active', { activeSectionId: 'profile' as const }],
|
||||
['missing candidate', { candidateId: null }],
|
||||
@@ -265,4 +318,144 @@ describe('clock-driven sign lifecycle', () => {
|
||||
controller.reset();
|
||||
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('resolves a rapid multi-section jump to the latest active destination', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 20,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
acceleration: 1,
|
||||
}))).toMatchObject({
|
||||
targetId: 'selected-builds',
|
||||
phase: 'anticipate',
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces changing jump targets and reveals only the latest after a readable stale sign departs', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 1_300,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 1_500,
|
||||
deltaY: 1_200,
|
||||
crossedBoundaries: 2,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_600,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
expect(controller.sample(sample({
|
||||
now: 3_300,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toMatchObject({ targetId: 'toolkit', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('retains the latest jump target first observed on the departure-completion frame', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_300,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('starts the readable dwell clock on the first rendered dwell frame after a delayed gap', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 5_000,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 6_399,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 6_400,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('does not retrigger a completed route sign during small boundary oscillations', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_700,
|
||||
direction: -1,
|
||||
deltaY: -16,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'start',
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_800,
|
||||
direction: 1,
|
||||
deltaY: 16,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user