@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
44import { createRoot } from "react-dom/client" ;
55import { TimelineAutomationLane } from "./TimelineAutomationLane" ;
66import { PAD_X } from "./automationLaneGeometry" ;
7+ import { AUTOMATION_LANE_H } from "./automationLaneHeight" ;
78import type { HfAudioFxChain } from "@hyperframes/core/audio-fx" ;
89import {
910 resolveAutomationRange ,
@@ -80,10 +81,24 @@ function renderNested(node: React.ReactElement): {
8081function fire (
8182 el : Element ,
8283 type : string ,
83- init : { clientX ?: number ; clientY ?: number ; button ?: number } = { } ,
84+ init : {
85+ clientX ?: number ;
86+ clientY ?: number ;
87+ button ?: number ;
88+ altKey ?: boolean ;
89+ shiftKey ?: boolean ;
90+ } = { } ,
8491) : void {
8592 const event = new Event ( type , { bubbles : true , cancelable : true } ) ;
86- Object . assign ( event , { clientX : 0 , clientY : 0 , button : 0 , pointerId : 1 , ...init } ) ;
93+ Object . assign ( event , {
94+ clientX : 0 ,
95+ clientY : 0 ,
96+ button : 0 ,
97+ pointerId : 1 ,
98+ altKey : false ,
99+ shiftKey : false ,
100+ ...init ,
101+ } ) ;
87102 act ( ( ) => {
88103 el . dispatchEvent ( event ) ;
89104 } ) ;
@@ -443,3 +458,153 @@ describe("TimelineAutomationLane", () => {
443458 expect ( Math . max ( ...ys ) ) . toBeGreaterThan ( Math . min ( ...ys ) + 30 ) ;
444459 } ) ;
445460} ) ;
461+
462+ describe ( "TimelineAutomationLane modifiers" , ( ) => {
463+ /** The lane's own box, so pointer coordinates map to clip time and value. */
464+ const BOX = { left : 100 , top : 0 , width : 400 + PAD * 2 , height : AUTOMATION_LANE_H } ;
465+
466+ /** x for a clip time, y for a 0..1 unit height, in client coordinates. The
467+ * 6px inset and the height have to match the lane's own, or a point sits
468+ * outside the grab radius and a press silently does nothing. */
469+ const at = ( t : number , unit : number ) => ( {
470+ clientX : BOX . left + PAD + ( t / 4 ) * 400 ,
471+ clientY : BOX . top + 6 + ( 1 - unit ) * ( AUTOMATION_LANE_H - 12 ) ,
472+ } ) ;
473+
474+ const mount = ( automation : HfAutomation , over : Record < string , unknown > = { } ) => {
475+ const base = laneProps ( { automation, ...over } ) ;
476+ // Narrowed once here: laneProps types these as the prop signature, and every
477+ // assertion below reads the calls the lane made.
478+ const props = {
479+ ...base ,
480+ onPreview : base . onPreview as ReturnType < typeof vi . fn > ,
481+ onCommit : base . onCommit as ReturnType < typeof vi . fn > ,
482+ } ;
483+ const { container } = render ( < TimelineAutomationLane { ...props } /> ) ;
484+ const svg = container . querySelector ( "svg" ) ! ;
485+ stubBox ( svg , BOX ) ;
486+ return { container, svg, props } ;
487+ } ;
488+
489+ it ( "bends a segment when it is Alt-dragged, and leaves the points where they were" , ( ) => {
490+ // `curve` was honoured everywhere it is read — drawn, sampled in preview,
491+ // baked into the render — with no gesture that could set it.
492+ const { svg, props } = mount ( ramp ) ;
493+ fire ( svg , "pointerdown" , { ...at ( 2 , 0.5 ) , altKey : true } ) ;
494+ fire ( svg , "pointermove" , { ...at ( 2 , 0.85 ) , altKey : true } ) ;
495+ const previewed = props . onPreview . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ;
496+ const points = previewed ?. lanes [ 0 ] ?. points ?? [ ] ;
497+ expect ( points [ 0 ] ?. curve ) . toBeDefined ( ) ;
498+ expect ( points [ 0 ] ?. curve ) . not . toBe ( 0 ) ;
499+ // The breakpoints themselves are untouched: only the shape between them moved.
500+ expect ( points . map ( ( p ) => [ p . t , p . v ] ) ) . toEqual ( [
501+ [ 0 , 1 ] ,
502+ [ 4 , 0 ] ,
503+ ] ) ;
504+ } ) ;
505+
506+ it ( "straightens a segment on Alt-double-click" , ( ) => {
507+ const curved : HfAutomation = {
508+ version : 1 ,
509+ lanes : [
510+ {
511+ target : "volume" ,
512+ points : [
513+ { t : 0 , v : 1 , curve : 0.6 } ,
514+ { t : 4 , v : 0 } ,
515+ ] ,
516+ } ,
517+ ] ,
518+ } ;
519+ const { svg, props } = mount ( curved ) ;
520+ fire ( svg , "dblclick" , { ...at ( 2 , 0.5 ) , altKey : true } ) ;
521+ const committed = props . onCommit . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ;
522+ expect ( committed ?. lanes [ 0 ] ?. points [ 0 ] ?. curve ) . toBeUndefined ( ) ;
523+ } ) ;
524+
525+ it ( "locks a Shift-drag to one axis" , ( ) => {
526+ const { svg, props } = mount ( ramp ) ;
527+ // Grab the point at t=0, v=1 (top left) and pull mostly sideways.
528+ fire ( svg , "pointerdown" , at ( 0 , 1 ) ) ;
529+ fire ( svg , "pointermove" , { ...at ( 2 , 0.9 ) , shiftKey : true } ) ;
530+ const points =
531+ ( props . onPreview . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ) ?. lanes [ 0 ] ?. points ?? [ ] ;
532+ const moved = points . find ( ( p ) => p . t > 0.5 ) ;
533+ expect ( moved ) . toBeDefined ( ) ;
534+ // Value held at exactly where the drag started, despite the vertical travel.
535+ expect ( moved ?. v ) . toBe ( 1 ) ;
536+ } ) ;
537+
538+ it ( "snaps a dragged point to the beat grid" , ( ) => {
539+ // By eye, "on the beat" and "20 ms off the beat" look identical.
540+ const { svg, props } = mount ( ramp , { snapTimes : [ 2 ] } ) ;
541+ fire ( svg , "pointerdown" , at ( 0 , 1 ) ) ;
542+ fire ( svg , "pointermove" , at ( 2.02 , 1 ) ) ;
543+ const points =
544+ ( props . onPreview . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ) ?. lanes [ 0 ] ?. points ?? [ ] ;
545+ expect ( points . find ( ( p ) => p . t > 0.5 ) ?. t ) . toBe ( 2 ) ;
546+ } ) ;
547+
548+ it ( "ignores the grid while Alt is held" , ( ) => {
549+ const { svg, props } = mount ( ramp , { snapTimes : [ 2 ] } ) ;
550+ fire ( svg , "pointerdown" , at ( 0 , 1 ) ) ;
551+ fire ( svg , "pointermove" , { ...at ( 2.02 , 1 ) , altKey : true } ) ;
552+ const points =
553+ ( props . onPreview . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ) ?. lanes [ 0 ] ?. points ?? [ ] ;
554+ expect ( points . find ( ( p ) => p . t > 0.5 ) ?. t ) . toBeCloseTo ( 2.02 , 2 ) ;
555+ } ) ;
556+
557+ it ( "takes a second gesture in the same lane, not just the first" , ( ) => {
558+ // The panel had exactly this bug: one edit worked and every later one was
559+ // swallowed, because the live write skips the resync the next edit reads.
560+ const { svg, props } = mount ( ramp ) ;
561+ fire ( svg , "pointerdown" , at ( 0 , 1 ) ) ;
562+ fire ( svg , "pointermove" , at ( 1 , 0.8 ) ) ;
563+ fire ( svg , "pointerup" , at ( 1 , 0.8 ) ) ;
564+ fire ( svg , "pointerdown" , at ( 4 , 0 ) ) ;
565+ fire ( svg , "pointermove" , at ( 3 , 0.4 ) ) ;
566+ const points =
567+ ( props . onPreview . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ) ?. lanes [ 0 ] ?. points ?? [ ] ;
568+ // Both ends moved: the first gesture's point is off t=0, the second's off t=4.
569+ expect ( points . map ( ( p ) => Number ( p . t . toFixed ( 2 ) ) ) ) . toEqual ( [ 1 , 3 ] ) ;
570+ } ) ;
571+
572+ it ( "types an exact value into a point" , ( ) => {
573+ // -6.0 dB is not a pixel you can find by dragging.
574+ const { container, svg, props } = mount ( ramp ) ;
575+ fire ( svg , "dblclick" , at ( 0 , 1 ) ) ;
576+ const input = container . querySelector < HTMLInputElement > ( ".hf-automation-value" ) ;
577+ expect ( input ) . not . toBeNull ( ) ;
578+ act ( ( ) => {
579+ Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , "value" ) ?. set ?. call (
580+ input ,
581+ "0.25" ,
582+ ) ;
583+ input ?. dispatchEvent ( new Event ( "input" , { bubbles : true } ) ) ;
584+ } ) ;
585+ fire ( input ! , "keydown" , { } ) ;
586+ const key = new Event ( "keydown" , { bubbles : true , cancelable : true } ) ;
587+ Object . assign ( key , { key : "Enter" } ) ;
588+ act ( ( ) => {
589+ input ?. dispatchEvent ( key ) ;
590+ } ) ;
591+ const committed = props . onCommit . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ;
592+ expect ( committed ?. lanes [ 0 ] ?. points [ 0 ] ?. v ) . toBe ( 0.25 ) ;
593+ } ) ;
594+
595+ it ( "clamps a typed value to the parameter's range" , ( ) => {
596+ const { container, svg, props } = mount ( ramp ) ;
597+ fire ( svg , "dblclick" , at ( 0 , 1 ) ) ;
598+ const input = container . querySelector < HTMLInputElement > ( ".hf-automation-value" ) ;
599+ act ( ( ) => {
600+ Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , "value" ) ?. set ?. call ( input , "99" ) ;
601+ input ?. dispatchEvent ( new Event ( "input" , { bubbles : true } ) ) ;
602+ } ) ;
603+ // React listens for focusout, not blur — blur does not bubble.
604+ act ( ( ) => {
605+ input ?. dispatchEvent ( new Event ( "focusout" , { bubbles : true } ) ) ;
606+ } ) ;
607+ const committed = props . onCommit . mock . calls . at ( - 1 ) ?. [ 0 ] as HfAutomation | undefined ;
608+ expect ( committed ?. lanes [ 0 ] ?. points [ 0 ] ?. v ) . toBe ( VOLUME_RANGE . max ) ;
609+ } ) ;
610+ } ) ;
0 commit comments