diff --git a/src/services/__tests__/gestureComposer.test.ts b/src/services/__tests__/gestureComposer.test.ts new file mode 100644 index 00000000..56bdff29 --- /dev/null +++ b/src/services/__tests__/gestureComposer.test.ts @@ -0,0 +1,109 @@ +import { composeGestureHandlers, GestureComposer } from '../gestureComposer'; +import type { ComposedGestureResult } from '../gestureComposer'; + +const SWIPE_SAMPLE = { dx: 90, dy: 5, vx: 0.4, vy: 0 }; +const TAP_SAMPLE = { dx: 5, dy: 2, vx: 0.02, vy: 0 }; + +describe('GestureComposer', () => { + describe('process', () => { + it('returns swipe result for a valid horizontal swipe', () => { + const composer = new GestureComposer({ enableHaptics: false }); + const result = composer.process(SWIPE_SAMPLE); + expect(result.priority).toBe('swipe'); + expect(result.direction).toBe('right'); + expect(result.swipe.isValid).toBe(true); + }); + + it('returns tap result for minimal motion without long press', () => { + const composer = new GestureComposer({ enableHaptics: false }); + const result = composer.process(TAP_SAMPLE, false); + expect(result.priority).toBe('tap'); + }); + + it('returns long-press when triggered with no swipe', () => { + const composer = new GestureComposer({ enableHaptics: false }); + const result = composer.process(TAP_SAMPLE, true); + expect(result.priority).toBe('long-press'); + }); + + it('includes the original sample in the result', () => { + const composer = new GestureComposer({ enableHaptics: false }); + const result = composer.process(SWIPE_SAMPLE); + expect(result.sample).toEqual(SWIPE_SAMPLE); + }); + + it('includes a non-empty debugLabel', () => { + const composer = new GestureComposer({ enableHaptics: false }); + const result = composer.process(SWIPE_SAMPLE); + expect(result.debugLabel.length).toBeGreaterThan(0); + expect(result.debugLabel).toContain('priority=swipe'); + }); + }); + + describe('handler management', () => { + it('calls registered onGesture handler', () => { + const calls: ComposedGestureResult[] = []; + const composer = new GestureComposer({ + enableHaptics: false, + onGesture: (r) => calls.push(r), + }); + composer.process(SWIPE_SAMPLE); + expect(calls.length).toBe(1); + expect(calls[0].priority).toBe('swipe'); + }); + + it('addHandler: chains additional handlers', () => { + const results: string[] = []; + const composer = new GestureComposer({ enableHaptics: false }); + composer.addHandler((r) => results.push(`first:${r.priority}`)); + composer.addHandler((r) => results.push(`second:${r.priority}`)); + composer.process(SWIPE_SAMPLE); + expect(results).toEqual(['first:swipe', 'second:swipe']); + }); + + it('removeHandler: stops calling removed handler', () => { + const calls: number[] = []; + const handler = () => calls.push(1); + const composer = new GestureComposer({ enableHaptics: false }); + composer.addHandler(handler); + composer.process(SWIPE_SAMPLE); + composer.removeHandler(handler); + composer.process(SWIPE_SAMPLE); + expect(calls.length).toBe(1); + }); + + it('returns this for fluent chaining', () => { + const composer = new GestureComposer({ enableHaptics: false }); + const result = composer.addHandler(() => {}); + expect(result).toBe(composer); + }); + }); +}); + +describe('composeGestureHandlers', () => { + it('calls all composed handlers in order', () => { + const log: string[] = []; + const composed = composeGestureHandlers( + () => log.push('a'), + () => log.push('b'), + () => log.push('c'), + ); + + const mockResult = {} as ComposedGestureResult; + composed(mockResult); + expect(log).toEqual(['a', 'b', 'c']); + }); + + it('passes the same result to all handlers', () => { + const received: ComposedGestureResult[] = []; + const composer = new GestureComposer({ enableHaptics: false }); + composer.addHandler( + composeGestureHandlers( + (r) => received.push(r), + (r) => received.push(r), + ) + ); + composer.process(SWIPE_SAMPLE); + expect(received[0]).toBe(received[1]); + }); +}); diff --git a/src/services/__tests__/gestureService.test.ts b/src/services/__tests__/gestureService.test.ts index db817d2c..18771d72 100644 --- a/src/services/__tests__/gestureService.test.ts +++ b/src/services/__tests__/gestureService.test.ts @@ -35,3 +35,72 @@ describe('gestureService', () => { expect(label).toContain('reason=accepted'); }); }); + +// ── Additional gesture validation coverage ──────────────────────────────── + +describe('validateHorizontalSwipe — extended', () => { + it('rejects zero motion', () => { + const result = validateHorizontalSwipe({ dx: 0, dy: 0, vx: 0, vy: 0 }); + expect(result.isValid).toBe(false); + expect(result.direction).toBe('none'); + expect(result.reason).toBe('no-horizontal-motion'); + }); + + it('rejects below-threshold distance with low velocity', () => { + const result = validateHorizontalSwipe({ dx: 20, dy: 2, vx: 0.05, vy: 0.01 }); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('below-threshold'); + }); + + it('accepts left swipe', () => { + const result = validateHorizontalSwipe({ dx: -80, dy: 5, vx: -0.35, vy: 0 }); + expect(result.isValid).toBe(true); + expect(result.direction).toBe('left'); + }); + + it('accepts swipe that meets distance threshold even at low velocity', () => { + const result = validateHorizontalSwipe({ dx: 70, dy: 10, vx: 0.1, vy: 0 }); + expect(result.isValid).toBe(true); + }); + + it('result contains all required fields', () => { + const result = validateHorizontalSwipe({ dx: 90, dy: 5, vx: 0.4, vy: 0 }); + expect(typeof result.isValid).toBe('boolean'); + expect(typeof result.direction).toBe('string'); + expect(typeof result.priority).toBe('string'); + expect(typeof result.reason).toBe('string'); + }); +}); + +// ── resolveGesturePriority ───────────────────────────────────────────────── + +describe('resolveGesturePriority', () => { + it('swipe wins over long-press when swipe is valid', () => { + const valid = validateHorizontalSwipe({ dx: 90, dy: 5, vx: 0.4, vy: 0 }); + expect(resolveGesturePriority(valid, true)).toBe('swipe'); + }); + + it('tap when neither swipe nor long-press', () => { + const invalid = validateHorizontalSwipe({ dx: 5, dy: 1, vx: 0.01, vy: 0 }); + expect(resolveGesturePriority(invalid, false)).toBe('tap'); + }); +}); + +// ── buildGestureDebugLabel ───────────────────────────────────────────────── + +describe('buildGestureDebugLabel', () => { + it('includes gesture type in label', () => { + const sample = { dx: 90, dy: 3, vx: 0.4, vy: 0 }; + const result = validateHorizontalSwipe(sample); + const label = buildGestureDebugLabel(result, sample); + expect(label).toContain('gesture=swipe'); + }); + + it('includes dx and dy values', () => { + const sample = { dx: 90, dy: 3, vx: 0.4, vy: 0 }; + const result = validateHorizontalSwipe(sample); + const label = buildGestureDebugLabel(result, sample); + expect(label).toContain('dx='); + expect(label).toContain('dy='); + }); +}); diff --git a/src/services/gestureComposer.ts b/src/services/gestureComposer.ts new file mode 100644 index 00000000..1dd6be96 --- /dev/null +++ b/src/services/gestureComposer.ts @@ -0,0 +1,85 @@ +import { + type GestureDirection, + type GesturePriority, + type GestureSample, + type GestureValidationResult, + resolveGesturePriority, + triggerGestureFeedback, + validateHorizontalSwipe, +} from './gestureService'; + +export type GestureHandlerFn = (result: ComposedGestureResult) => void; + +export interface ComposedGestureResult { + priority: GesturePriority; + direction: GestureDirection; + swipe: GestureValidationResult; + longPressTriggered: boolean; + sample: GestureSample; + debugLabel: string; +} + +export interface GestureComposerOptions { + enableHaptics?: boolean; + onGesture?: GestureHandlerFn; +} + +export class GestureComposer { + private handlers: GestureHandlerFn[] = []; + private enableHaptics: boolean; + + constructor(options: GestureComposerOptions = {}) { + this.enableHaptics = options.enableHaptics ?? true; + if (options.onGesture) this.handlers.push(options.onGesture); + } + + addHandler(handler: GestureHandlerFn): this { + this.handlers.push(handler); + return this; + } + + removeHandler(handler: GestureHandlerFn): this { + this.handlers = this.handlers.filter((h) => h !== handler); + return this; + } + + process(sample: GestureSample, longPressTriggered = false): ComposedGestureResult { + const swipe = validateHorizontalSwipe(sample); + const priority = resolveGesturePriority(swipe, longPressTriggered); + + const result: ComposedGestureResult = { + priority, + direction: swipe.direction, + swipe, + longPressTriggered, + sample, + debugLabel: buildComposedDebugLabel(priority, swipe, sample), + }; + + if (this.enableHaptics) { + triggerGestureFeedback(priority); + } + + this.handlers.forEach((h) => h(result)); + return result; + } +} + +function buildComposedDebugLabel( + priority: GesturePriority, + swipe: GestureValidationResult, + sample: GestureSample +): string { + return [ + `priority=${priority}`, + `direction=${swipe.direction}`, + `dx=${sample.dx.toFixed(1)}`, + `dy=${sample.dy.toFixed(1)}`, + `vx=${sample.vx.toFixed(2)}`, + `reason=${swipe.reason}`, + ].join(' '); +} + +export function composeGestureHandlers(...handlers: GestureHandlerFn[]): GestureHandlerFn { + return (result) => handlers.forEach((h) => h(result)); +}