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 => ({ now: 0, source: 'passive-scroll', 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 }, { 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(); 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('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([ ['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 }], ])('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('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('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', activeSectionId: 'contact', candidateId: null, candidateDistanceVh: null, }))).toBeNull(); expect(controller.sample(sample({ now: 3_500, source: 'explicit-navigation', activeSectionId: 'start', }))).toBeNull(); expect(controller.sample(sample({ now: 3_600 }))).toMatchObject({ targetId: 'profile', phase: 'anticipate', }); }); });