11// @vitest -environment happy-dom
22import { act } from "react" ;
3- import { describe , expect , it , vi } from "vitest" ;
4- import { createRoot } from "react-dom/client" ;
3+ import { afterEach , describe , expect , it , vi } from "vitest" ;
4+ import { createRoot , type Root } from "react-dom/client" ;
55import { usePlayerStore } from "../player/store/playerStore" ;
66import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard" ;
7+ import {
8+ clearAutomationClipboard ,
9+ copyRange ,
10+ readClipboard ,
11+ } from "../player/components/automationClipboard" ;
12+ import { VOLUME_RANGE } from "@hyperframes/core/audio-automation" ;
713import type {
814 AutomationLaneBinding ,
915 UseAutomationLanesResult ,
@@ -32,25 +38,54 @@ const key = (k: string) => {
3238 act ( ( ) => void document . dispatchEvent ( e ) ) ;
3339} ;
3440
41+ /** Cmd/Ctrl-modified key combo, returning the event so tests can inspect
42+ * `defaultPrevented` for the "falls through" cases. */
43+ const combo = ( k : string ) => {
44+ const e = new KeyboardEvent ( "keydown" , {
45+ key : k ,
46+ metaKey : true ,
47+ bubbles : true ,
48+ cancelable : true ,
49+ } ) ;
50+ act ( ( ) => void document . dispatchEvent ( e ) ) ;
51+ return e ;
52+ } ;
53+
3554describe ( "useAutomationSelectionKeyboard" , ( ) => {
55+ // Each setup() mounts a Host whose effect adds a document-level keydown
56+ // listener. Without unmounting the previous one, listeners from earlier
57+ // tests linger and can consume later tests' events first (stopping
58+ // propagation before the current test's own listener ever runs) — so this
59+ // must run before every test, not just the ones that call setup() twice.
60+ let mountedRoot : { root : Root ; host : HTMLElement } | null = null ;
61+ afterEach ( ( ) => {
62+ if ( ! mountedRoot ) return ;
63+ act ( ( ) => mountedRoot ?. root . unmount ( ) ) ;
64+ mountedRoot . host . remove ( ) ;
65+ mountedRoot = null ;
66+ } ) ;
67+
3668 const setup = ( binding : Partial < AutomationLaneBinding > ) => {
3769 const onCommit = vi . fn ( ) ;
38- const lanes : UseAutomationLanesResult = {
39- bind : ( ) => ( {
40- automation : {
41- version : 1 ,
42- lanes : [
43- {
44- target : "volume" ,
45- points : [
46- { t : 0 , v : 1 } ,
47- { t : 2 , v : 0.5 } ,
48- { t : 4 , v : 0 } ,
49- ] ,
50- } ,
70+ const automation = {
71+ version : 1 ,
72+ lanes : [
73+ {
74+ target : "volume" ,
75+ points : [
76+ { t : 0 , v : 1 } ,
77+ { t : 2 , v : 0.5 } ,
78+ { t : 4 , v : 0 } ,
5179 ] ,
5280 } ,
53- lanes : [ ] ,
81+ ] ,
82+ } ;
83+ const lanes : UseAutomationLanesResult = {
84+ bind : ( ) => ( {
85+ automation,
86+ // Same list as `automation.lanes`, matching useAutomationLanes' real
87+ // binding — the paste fallback (no active selection) reads this.
88+ lanes : automation . lanes ,
5489 chain : null ,
5590 onPreview : vi . fn ( ) ,
5691 onCommit,
@@ -64,7 +99,9 @@ describe("useAutomationSelectionKeyboard", () => {
6499 } ;
65100 const host = document . createElement ( "div" ) ;
66101 document . body . append ( host ) ;
67- act ( ( ) => createRoot ( host ) . render ( < Host lanes = { lanes } /> ) ) ;
102+ const root = createRoot ( host ) ;
103+ act ( ( ) => root . render ( < Host lanes = { lanes } /> ) ) ;
104+ mountedRoot = { root, host } ;
68105 return { onCommit } ;
69106 } ;
70107
@@ -101,4 +138,62 @@ describe("useAutomationSelectionKeyboard", () => {
101138 expect ( onCommit ) . not . toHaveBeenCalled ( ) ;
102139 input . remove ( ) ;
103140 } ) ;
141+
142+ it ( "Cmd+C copies the active selection" , ( ) => {
143+ clearAutomationClipboard ( ) ;
144+ usePlayerStore . setState ( { elements : [ bgmElement ] , selectedElementId : "bgm" } ) ;
145+ usePlayerStore
146+ . getState ( )
147+ . setAutomationSelection ( { elementKey : "bgm" , target : "volume" , t0 : 2 , t1 : 4 } ) ;
148+ setup ( { } ) ;
149+ combo ( "c" ) ;
150+ const entry = readClipboard ( ) ;
151+ expect ( entry ?. span ) . toBe ( 2 ) ;
152+ expect ( entry ?. points . map ( ( p ) => p . t ) ) . toEqual ( [ 0 , 2 ] ) ;
153+ } ) ;
154+
155+ it ( "Cmd+V with no selection pastes at the playhead and selects the pasted span" , ( ) => {
156+ clearAutomationClipboard ( ) ;
157+ // Duration wide enough that the playhead (5s) is not clamped down by the
158+ // 0..duration-span bound — this is a paste-at-playhead test, not a
159+ // clamp-boundary test.
160+ usePlayerStore . setState ( {
161+ elements : [ { ...bgmElement , duration : 10 } ] ,
162+ selectedElementId : "bgm" ,
163+ currentTime : 5 ,
164+ } ) ;
165+ usePlayerStore
166+ . getState ( )
167+ . setAutomationSelection ( { elementKey : "bgm" , target : "volume" , t0 : 2 , t1 : 4 } ) ;
168+ const { onCommit } = setup ( { } ) ;
169+ combo ( "c" ) ;
170+ expect ( readClipboard ( ) ?. span ) . toBe ( 2 ) ;
171+ usePlayerStore . getState ( ) . clearAutomationSelection ( ) ;
172+
173+ combo ( "v" ) ;
174+ const written = onCommit . mock . calls . at ( - 1 ) ?. [ 0 ] ;
175+ const times = ( written ?. lanes ?. [ 0 ] ?. points ?? [ ] ) . map ( ( p : { t : number } ) => p . t ) ;
176+ expect ( times ) . toContain ( 5 ) ; // playhead 5s − element start 0
177+ expect ( times ) . toContain ( 7 ) ; // + clipboard span 2
178+
179+ // Pasting again immediately should land right after the first paste.
180+ expect ( usePlayerStore . getState ( ) . automationSelection ) . toEqual ( {
181+ elementKey : "bgm" ,
182+ target : "volume" ,
183+ t0 : 5 ,
184+ t1 : 7 ,
185+ } ) ;
186+ } ) ;
187+
188+ it ( "Cmd+V with clipboard content but no resolvable element falls through" , ( ) => {
189+ clearAutomationClipboard ( ) ;
190+ copyRange ( { target : "volume" , points : [ { t : 0 , v : 1 } ] } , VOLUME_RANGE , 0 , 1 ) ;
191+ expect ( readClipboard ( ) ) . not . toBeNull ( ) ;
192+ usePlayerStore . setState ( { elements : [ bgmElement ] , selectedElementId : null } ) ;
193+ usePlayerStore . getState ( ) . clearAutomationSelection ( ) ;
194+ const { onCommit } = setup ( { } ) ;
195+ const e = combo ( "v" ) ;
196+ expect ( e . defaultPrevented ) . toBe ( false ) ;
197+ expect ( onCommit ) . not . toHaveBeenCalled ( ) ;
198+ } ) ;
104199} ) ;
0 commit comments