import { describe, expect, it } from 'vitest'; import { createSignController } from '@/journey/sign-controller'; import type { SignControllerSample } from '@/journey/types'; const sample = ( overrides: Partial = {}, ): 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' }); }); });