fix: make nightdrive signage reliable and legible

This commit is contained in:
2026-09-03 17:07:00 +03:00
parent 4404c07f93
commit b075041918
50 changed files with 4435 additions and 200 deletions

View File

@@ -8,6 +8,8 @@ const sample = (
): SignControllerSample => ({
now: 0,
source: 'passive-scroll',
navigationEpoch: 0,
navigationDestinationId: null,
direction: 1,
acceleration: 0,
deltaY: 16,
@@ -26,12 +28,12 @@ describe('clock-driven sign lifecycle', () => {
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: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', 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: 'right', triggerMaximumVh: 0.55 },
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
]);
});
@@ -103,6 +105,36 @@ describe('clock-driven sign lifecycle', () => {
});
});
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' });
@@ -166,7 +198,6 @@ describe('clock-driven sign lifecycle', () => {
});
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 }],
@@ -178,6 +209,459 @@ describe('clock-driven sign lifecycle', () => {
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 }],
@@ -435,6 +919,69 @@ describe('clock-driven sign lifecycle', () => {
}))).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());
@@ -459,6 +1006,164 @@ describe('clock-driven sign lifecycle', () => {
}))).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());
@@ -469,6 +1174,8 @@ describe('clock-driven sign lifecycle', () => {
expect(controller.sample(sample({
now: 3_400,
source: 'explicit-navigation',
navigationEpoch: 1,
navigationDestinationId: 'contact',
activeSectionId: 'contact',
candidateId: null,
candidateDistanceVh: null,
@@ -476,6 +1183,8 @@ describe('clock-driven sign lifecycle', () => {
expect(controller.sample(sample({
now: 3_500,
source: 'explicit-navigation',
navigationEpoch: 2,
navigationDestinationId: 'start',
activeSectionId: 'start',
}))).toBeNull();
@@ -484,4 +1193,64 @@ describe('clock-driven sign lifecycle', () => {
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',
});
});
});