1257 lines
40 KiB
TypeScript
1257 lines
40 KiB
TypeScript
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 = (
|
|
overrides: Partial<SignControllerSample> = {},
|
|
): SignControllerSample => ({
|
|
now: 0,
|
|
source: 'passive-scroll',
|
|
navigationEpoch: 0,
|
|
navigationDestinationId: null,
|
|
direction: 1,
|
|
acceleration: 0,
|
|
deltaY: 16,
|
|
viewportWidth: 1_440,
|
|
viewportHeight: 800,
|
|
crossedBoundaries: 0,
|
|
candidateId: 'profile',
|
|
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, dwellOffsetX: -1.6 },
|
|
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
|
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'outer-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: 'outer-right', triggerMaximumVh: 0.55 },
|
|
]);
|
|
});
|
|
|
|
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,
|
|
viewportProfile: 'wide',
|
|
});
|
|
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('uses the portrait section gap instead of latching behind a full-width panel', () => {
|
|
const controller = createSignController();
|
|
expect(controller.sample(sample({
|
|
viewportWidth: 390,
|
|
viewportHeight: 844,
|
|
candidateDistanceVh: 0.3,
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 16,
|
|
viewportWidth: 390,
|
|
viewportHeight: 844,
|
|
candidateDistanceVh: 0.22,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate', viewportProfile: 'tall' });
|
|
});
|
|
|
|
it('uses the short-landscape section gap when no horizontal corridor is wide enough', () => {
|
|
const controller = createSignController();
|
|
expect(controller.sample(sample({
|
|
viewportWidth: 844,
|
|
viewportHeight: 390,
|
|
candidateDistanceVh: 0.42,
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 16,
|
|
viewportWidth: 844,
|
|
viewportHeight: 390,
|
|
candidateDistanceVh: 0.38,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate', viewportProfile: 'short' });
|
|
});
|
|
|
|
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_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 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();
|
|
expect(early.needsFrame()).toBe(false);
|
|
|
|
const readable = createSignController();
|
|
readable.sample(sample());
|
|
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', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0 }));
|
|
|
|
expect(controller.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
|
|
phase: 'dwell',
|
|
});
|
|
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({
|
|
phase: 'dwell',
|
|
});
|
|
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['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('consumes an eligible navigator destination once without requiring scroll input', () => {
|
|
const controller = createSignController();
|
|
const navigation = sample({
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'selected-builds',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
crossedBoundaries: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 2,
|
|
});
|
|
|
|
expect(controller.sample(navigation)).toMatchObject({
|
|
targetId: 'selected-builds',
|
|
phase: 'anticipate',
|
|
navigationArrival: true,
|
|
});
|
|
expect(controller.sample({ ...navigation, now: 1_200 })).toMatchObject({
|
|
targetId: 'selected-builds',
|
|
phase: 'dwell',
|
|
});
|
|
expect(controller.sample({ ...navigation, now: 2_600 })).toMatchObject({
|
|
targetId: 'selected-builds',
|
|
phase: 'depart',
|
|
progress: 0,
|
|
});
|
|
expect(controller.sample({ ...navigation, now: 3_300 })).toBeNull();
|
|
expect(controller.sample({ ...navigation, now: 4_000 })).toBeNull();
|
|
expect(controller.needsFrame()).toBe(false);
|
|
});
|
|
|
|
it('coalesces rapid navigator clicks to the latest concealed target without label mutation', () => {
|
|
const controller = createSignController();
|
|
|
|
expect(controller.sample(sample({
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
|
expect(controller.sample(sample({
|
|
now: 100,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'selected-builds',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
|
|
expect(controller.sample(sample({
|
|
now: 1_300,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'selected-builds',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}))).toMatchObject({ targetId: 'selected-builds', phase: 'dwell' });
|
|
});
|
|
|
|
it('does not queue a second presentation for a repeated same-target navigator click', () => {
|
|
const controller = createSignController();
|
|
const navigation = sample({
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'selected-builds',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
});
|
|
controller.sample(navigation);
|
|
controller.sample({ ...navigation, now: 1_200 });
|
|
|
|
expect(controller.sample({
|
|
...navigation,
|
|
now: 1_300,
|
|
navigationEpoch: 2,
|
|
})).toMatchObject({ targetId: 'selected-builds', phase: 'dwell' });
|
|
controller.sample({ ...navigation, now: 2_600, navigationEpoch: 2 });
|
|
controller.sample({ ...navigation, now: 3_300, navigationEpoch: 2 });
|
|
expect(controller.sample({
|
|
...navigation,
|
|
now: 3_650,
|
|
navigationEpoch: 2,
|
|
})).toBeNull();
|
|
expect(controller.sample({
|
|
...navigation,
|
|
now: 4_000,
|
|
navigationEpoch: 3,
|
|
activeSectionId: 'selected-builds',
|
|
})).toBeNull();
|
|
expect(controller.needsFrame()).toBe(false);
|
|
});
|
|
|
|
it('promotes a rearmed passive sign when the prior navigator target is clicked again', () => {
|
|
const controller = createSignController();
|
|
const navigation = sample({
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
});
|
|
controller.sample(navigation);
|
|
controller.sample({ ...navigation, now: 1_200, activeSectionId: 'profile' });
|
|
controller.sample({ ...navigation, now: 2_600, activeSectionId: 'profile' });
|
|
controller.sample({ ...navigation, now: 3_300, activeSectionId: 'profile' });
|
|
controller.sample(sample({
|
|
now: 3_400,
|
|
direction: -1,
|
|
deltaY: -800,
|
|
crossedBoundaries: 1,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 3_650,
|
|
direction: 1,
|
|
deltaY: 120,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 4_850,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
candidateId: 'experience',
|
|
}));
|
|
|
|
expect(controller.sample({
|
|
...navigation,
|
|
now: 4_900,
|
|
navigationEpoch: 2,
|
|
activeSectionId: 'profile',
|
|
})).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'dwell',
|
|
navigationArrival: true,
|
|
});
|
|
});
|
|
|
|
it('preserves a full readable dwell when navigation promotes a late passive dwell', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
|
|
|
const promoted = sample({
|
|
now: 2_590,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
});
|
|
expect(controller.sample(promoted)).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'dwell',
|
|
navigationArrival: true,
|
|
});
|
|
expect(controller.sample({ ...promoted, now: 3_989 })).toMatchObject({
|
|
phase: 'dwell',
|
|
navigationArrival: true,
|
|
});
|
|
expect(controller.sample({ ...promoted, now: 3_990 })).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0,
|
|
navigationArrival: true,
|
|
});
|
|
});
|
|
|
|
it('promotes a rearmed sign queued during cooldown when its navigator target is clicked', () => {
|
|
const controller = createSignController();
|
|
const navigation = sample({
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
});
|
|
controller.sample(navigation);
|
|
controller.sample({ ...navigation, now: 1_200, activeSectionId: 'profile' });
|
|
controller.sample({ ...navigation, now: 2_600, activeSectionId: 'profile' });
|
|
controller.sample(sample({
|
|
now: 2_800,
|
|
direction: -1,
|
|
deltaY: -800,
|
|
crossedBoundaries: 1,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 3_300,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}));
|
|
expect(controller.sample(sample({
|
|
now: 3_400,
|
|
direction: 1,
|
|
deltaY: 120,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toBeNull();
|
|
expect(controller.needsFrame()).toBe(true);
|
|
expect(controller.sample({
|
|
...navigation,
|
|
now: 3_450,
|
|
navigationEpoch: 2,
|
|
activeSectionId: 'profile',
|
|
})).toBeNull();
|
|
|
|
expect(controller.sample({
|
|
...navigation,
|
|
now: 3_650,
|
|
navigationEpoch: 2,
|
|
activeSectionId: 'profile',
|
|
})).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
navigationArrival: true,
|
|
});
|
|
});
|
|
|
|
it('preserves a readable label and queues only the latest navigator destination', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 1_300,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'experience',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
|
expect(controller.sample(sample({
|
|
now: 1_400,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'toolkit',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
|
controller.sample(sample({
|
|
now: 2_600,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'toolkit',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 3_300,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'toolkit',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 3_650,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'toolkit',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
}))).toMatchObject({
|
|
targetId: 'toolkit',
|
|
phase: 'anticipate',
|
|
navigationArrival: true,
|
|
});
|
|
});
|
|
|
|
it('cancels an obsolete queued destination when navigation returns to the visible target', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 1_300,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'toolkit',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'toolkit',
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
|
expect(controller.sample(sample({
|
|
now: 1_400,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'dwell',
|
|
navigationArrival: true,
|
|
});
|
|
|
|
controller.sample(sample({
|
|
now: 2_800,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}));
|
|
controller.sample(sample({
|
|
now: 3_500,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 3_850,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toBeNull();
|
|
expect(controller.needsFrame()).toBe(false);
|
|
|
|
expect(controller.sample(sample({
|
|
now: 3_900,
|
|
direction: -1,
|
|
deltaY: -40,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.6,
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 4_000,
|
|
direction: 1,
|
|
deltaY: 40,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toBeNull();
|
|
});
|
|
|
|
it('honors a navigator return to a previously completed route after a readable sign', () => {
|
|
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 }));
|
|
controller.sample(sample({
|
|
now: 3_650,
|
|
activeSectionId: 'profile',
|
|
candidateId: 'experience',
|
|
candidateDistanceVh: 0.5,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 4_850,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
candidateId: 'experience',
|
|
}));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 5_000,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({ targetId: 'experience', phase: 'dwell' });
|
|
controller.sample(sample({
|
|
now: 6_250,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}));
|
|
controller.sample(sample({
|
|
now: 6_950,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 7_300,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
navigationArrival: true,
|
|
});
|
|
});
|
|
|
|
it('queues a fresh navigator arrival for the cooldown route after moving elsewhere', () => {
|
|
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_400,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'experience',
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 3_650,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
navigationArrival: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['multiple crossed sections', { crossedBoundaries: 2 }],
|
|
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
|
])('coalesces %s while preserving the readable dwell', (_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: 'dwell',
|
|
});
|
|
expect(controller.needsFrame()).toBe(true);
|
|
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'depart',
|
|
progress: 0,
|
|
opacity: 1,
|
|
});
|
|
});
|
|
|
|
it('preserves approach and minimum dwell when the authoritative section passes the target', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
|
|
expect(controller.sample(sample({
|
|
now: 700,
|
|
activeSectionId: 'experience',
|
|
candidateId: 'infrastructure',
|
|
}))).toMatchObject({
|
|
targetId: 'profile',
|
|
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', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0 }));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 1_350,
|
|
direction: 1,
|
|
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({ phase: 'dwell' });
|
|
expect(controller.sample(sample({
|
|
now: 2_600,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'experience',
|
|
candidateId: 'infrastructure',
|
|
}))).toMatchObject({ 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: 0.550_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' });
|
|
});
|
|
|
|
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('keeps a passed sign opaque until its spatial departure is well underway', () => {
|
|
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: 2_985,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0.55,
|
|
opacity: 1,
|
|
});
|
|
expect(controller.sample(sample({
|
|
now: 3_230,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
}))).toMatchObject({
|
|
phase: 'depart',
|
|
progress: 0.9,
|
|
opacity: expect.closeTo(0.04, 1),
|
|
});
|
|
});
|
|
|
|
it('discards a queued forward jump after a meaningful reverse retreat', () => {
|
|
const controller = createSignController();
|
|
controller.sample(sample());
|
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
|
|
|
controller.sample(sample({
|
|
now: 1_300,
|
|
deltaY: 2_400,
|
|
crossedBoundaries: 4,
|
|
activeSectionId: 'selected-builds',
|
|
candidateId: 'additional-work',
|
|
candidateDistanceVh: 0.4,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 1_500,
|
|
direction: -1,
|
|
deltaY: -2_400,
|
|
crossedBoundaries: 4,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}));
|
|
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_650,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: 'selected-builds',
|
|
candidateDistanceVh: 0.4,
|
|
}))).toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it('re-arms a completed sign only after retreating beyond the trigger hysteresis', () => {
|
|
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_500,
|
|
direction: -1,
|
|
deltaY: -600,
|
|
crossedBoundaries: 1,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 3_650,
|
|
direction: 1,
|
|
deltaY: 120,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
|
});
|
|
|
|
it('does not let a departing sign overwrite a meaningful reverse rearm', () => {
|
|
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: 2_800,
|
|
direction: -1,
|
|
deltaY: -800,
|
|
crossedBoundaries: 1,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
|
controller.sample(sample({
|
|
now: 3_300,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 3_400,
|
|
direction: 1,
|
|
deltaY: 120,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toBeNull();
|
|
expect(controller.needsFrame()).toBe(true);
|
|
expect(controller.sample(sample({
|
|
now: 3_650,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
|
});
|
|
|
|
it('re-arms every later route after one large reverse retreat', () => {
|
|
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 }));
|
|
controller.sample(sample({
|
|
now: 3_650,
|
|
activeSectionId: 'profile',
|
|
candidateId: 'experience',
|
|
candidateDistanceVh: 0.5,
|
|
}));
|
|
controller.sample(sample({
|
|
now: 4_850,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
candidateId: 'experience',
|
|
}));
|
|
controller.sample(sample({
|
|
now: 6_250,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'experience',
|
|
candidateId: 'infrastructure',
|
|
}));
|
|
controller.sample(sample({
|
|
now: 6_950,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'experience',
|
|
candidateId: 'infrastructure',
|
|
}));
|
|
|
|
controller.sample(sample({
|
|
now: 7_100,
|
|
direction: -1,
|
|
deltaY: -2_400,
|
|
crossedBoundaries: 2,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.8,
|
|
}));
|
|
controller.sample(sample({ now: 7_300 }));
|
|
controller.sample(sample({ now: 8_500, direction: 0, deltaY: 0 }));
|
|
controller.sample(sample({ now: 9_900, direction: 0, deltaY: 0 }));
|
|
controller.sample(sample({ now: 10_600, direction: 0, deltaY: 0 }));
|
|
|
|
expect(controller.sample(sample({
|
|
now: 10_950,
|
|
activeSectionId: 'profile',
|
|
candidateId: 'experience',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toMatchObject({ targetId: 'experience', phase: 'anticipate' });
|
|
});
|
|
|
|
it.each([
|
|
['an unrequested explicit jump', 'explicit-navigation' as const],
|
|
['history restoration', 'history-restoration' as const],
|
|
])('starts a fresh passive traversal after %s', (_label, source) => {
|
|
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_400,
|
|
source,
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: null,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: null,
|
|
candidateDistanceVh: null,
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 3_500,
|
|
source: 'passive-scroll',
|
|
navigationEpoch: 2,
|
|
direction: 1,
|
|
deltaY: 120,
|
|
activeSectionId: 'start',
|
|
candidateId: 'profile',
|
|
candidateDistanceVh: 0.5,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'anticipate' });
|
|
});
|
|
|
|
it('starts a fresh passive sign traversal after explicit navigation', () => {
|
|
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_400,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'contact',
|
|
activeSectionId: 'contact',
|
|
candidateId: null,
|
|
candidateDistanceVh: null,
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 3_500,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'start',
|
|
activeSectionId: 'start',
|
|
}))).toBeNull();
|
|
|
|
expect(controller.sample(sample({ now: 3_600 }))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
});
|
|
});
|
|
|
|
it('does not let a stale navigator sign re-complete a traversal restarted at Start', () => {
|
|
const controller = createSignController();
|
|
const profileNavigation = sample({
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 1,
|
|
navigationDestinationId: 'profile',
|
|
candidateId: null,
|
|
candidateDistanceVh: null,
|
|
});
|
|
controller.sample(profileNavigation);
|
|
controller.sample({
|
|
...profileNavigation,
|
|
now: 1_200,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
});
|
|
controller.sample({
|
|
...profileNavigation,
|
|
now: 2_600,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'profile',
|
|
});
|
|
|
|
expect(controller.sample(sample({
|
|
now: 2_800,
|
|
source: 'explicit-navigation',
|
|
navigationEpoch: 2,
|
|
navigationDestinationId: 'start',
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
candidateId: null,
|
|
candidateDistanceVh: null,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
|
expect(controller.sample(sample({
|
|
now: 2_900,
|
|
viewportWidth: 390,
|
|
viewportHeight: 844,
|
|
candidateDistanceVh: 0.22,
|
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart' });
|
|
expect(controller.sample(sample({
|
|
now: 3_300,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
activeSectionId: 'start',
|
|
}))).toBeNull();
|
|
expect(controller.sample(sample({
|
|
now: 3_650,
|
|
direction: 0,
|
|
deltaY: 0,
|
|
viewportWidth: 390,
|
|
viewportHeight: 844,
|
|
}))).toMatchObject({
|
|
targetId: 'profile',
|
|
phase: 'anticipate',
|
|
});
|
|
});
|
|
});
|