@@ -440,6 +440,73 @@ async function clickCanvasCell(page, rowToken, stepIndex) {
440440 await page . mouse . click ( point . x , point . y ) ;
441441}
442442
443+ async function hoverCanvasCell ( page , rowToken , stepIndex ) {
444+ await scrollCanvasCellIntoView ( page , rowToken , stepIndex ) ;
445+ const point = await page . evaluate ( ( target ) => window . __midiStudioV2App . instrumentGrid . timelineCanvasCellCenter ( target . rowToken , target . stepIndex ) , { rowToken, stepIndex } ) ;
446+ expect ( point ) . toBeTruthy ( ) ;
447+ await page . mouse . move ( point . x , point . y ) ;
448+ }
449+
450+ async function dragCanvasCells ( page , fromRowToken , fromStepIndex , toRowToken , toStepIndex ) {
451+ await scrollCanvasCellIntoView ( page , fromRowToken , fromStepIndex ) ;
452+ const points = await page . evaluate ( ( target ) => ( {
453+ from : window . __midiStudioV2App . instrumentGrid . timelineCanvasCellCenter ( target . fromRowToken , target . fromStepIndex ) ,
454+ to : window . __midiStudioV2App . instrumentGrid . timelineCanvasCellCenter ( target . toRowToken , target . toStepIndex )
455+ } ) , { fromRowToken, fromStepIndex, toRowToken, toStepIndex } ) ;
456+ expect ( points . from ) . toBeTruthy ( ) ;
457+ expect ( points . to ) . toBeTruthy ( ) ;
458+ await page . mouse . move ( points . from . x , points . from . y ) ;
459+ await page . mouse . down ( ) ;
460+ await page . mouse . move ( points . to . x , points . to . y , { steps : 8 } ) ;
461+ await page . mouse . up ( ) ;
462+ }
463+
464+ async function canvasCellPixel ( page , rowToken , stepIndex ) {
465+ return page . evaluate ( ( target ) => {
466+ const canvas = document . querySelector ( "[data-octave-timeline-canvas='true']" ) ;
467+ const point = window . __midiStudioV2App . instrumentGrid . timelineCanvasCellCenter ( target . rowToken , target . stepIndex ) ;
468+ const ratio = canvas . width / canvas . clientWidth ;
469+ const sample = canvas . getContext ( "2d" ) . getImageData ( Math . round ( ( point . x - canvas . getBoundingClientRect ( ) . left ) * ratio ) , Math . round ( ( point . y - canvas . getBoundingClientRect ( ) . top ) * ratio ) , 1 , 1 ) . data ;
470+ return Array . from ( sample ) ;
471+ } , { rowToken, stepIndex } ) ;
472+ }
473+
474+ async function emptyCanvasRun ( page , { lane = "lead" , length = 3 } = { } ) {
475+ return page . evaluate ( ( { lane, length } ) => {
476+ const app = window . __midiStudioV2App ;
477+ const result = app . lastInstrumentGridResult ;
478+ const state = app . instrumentGrid . timelineCanvasState ( ) ;
479+ const rows = state . rows . map ( ( row ) => row . value ) ;
480+ const hasNote = ( rowToken , stepIndex ) => result . timeline . some ( ( event ) => event . lane === lane
481+ && event . stepIndex === stepIndex
482+ && app . instrumentGrid . rowsForEvent ( event ) . includes ( rowToken ) ) ;
483+ for ( const rowToken of rows ) {
484+ for ( let stepIndex = 1 ; stepIndex <= result . totalSteps - length ; stepIndex += 1 ) {
485+ let empty = true ;
486+ for ( let offset = 0 ; offset < length ; offset += 1 ) {
487+ if ( hasNote ( rowToken , stepIndex + offset ) ) {
488+ empty = false ;
489+ break ;
490+ }
491+ }
492+ if ( empty ) {
493+ return { rowToken, stepIndex } ;
494+ }
495+ }
496+ }
497+ return null ;
498+ } , { lane, length } ) ;
499+ }
500+
501+ async function hasCanvasNote ( page , lane , rowToken , stepIndex ) {
502+ return page . evaluate ( ( target ) => {
503+ const app = window . __midiStudioV2App ;
504+ return app . lastInstrumentGridResult . timeline . some ( ( event ) => event . lane === target . lane
505+ && event . stepIndex === target . stepIndex
506+ && app . instrumentGrid . rowsForEvent ( event ) . includes ( target . rowToken ) ) ;
507+ } , { lane, rowToken, stepIndex } ) ;
508+ }
509+
443510function instrumentRow ( page , lane ) {
444511 return page . locator ( `.midi-studio-v2__instrument-row[data-lane="${ lane } "]` ) ;
445512}
@@ -738,7 +805,7 @@ test.describe("MIDI Studio V2", () => {
738805 expect ( keyboardAxisPixel . slice ( 0 , 3 ) . some ( ( channel ) => channel > 0 ) ) . toBe ( true ) ;
739806
740807 await clickCanvasCell ( page , "C6" , 2 ) ;
741- await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K T o g g l e d C 6 f o r L e a d ; v i s i b l e t i m e l i n e p l a y b a c k d a t a u p d a t e d \. / ) ;
808+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K P a i n t e d C 6 f o r L e a d a c r o s s t h e t i m e l i n e ; p l a y b a c k d a t a u p d a t e d \. / ) ;
742809 const canonicalEdit = await page . evaluate ( ( ) => {
743810 const app = window . __midiStudioV2App ;
744811 return {
@@ -809,7 +876,7 @@ test.describe("MIDI Studio V2", () => {
809876 expect ( scrollEvidence . scrollLeft ) . toBeGreaterThan ( 0 ) ;
810877 expect ( scrollEvidence . scrollTop ) . toBeGreaterThan ( 0 ) ;
811878 expect ( scrollEvidence . datasetScrollLeft ) . toBe ( String ( scrollEvidence . scrollLeft ) ) ;
812- expect ( scrollEvidence . topScrollLeft ) . toBe ( scrollEvidence . scrollLeft ) ;
879+ expect ( Math . abs ( scrollEvidence . topScrollLeft - scrollEvidence . scrollLeft ) ) . toBeLessThanOrEqual ( 1 ) ;
813880
814881 const zoomBefore = ( await canvasTimelineState ( page ) ) . cellSize ;
815882 await page . locator ( "#instrumentGridZoomInButton" ) . click ( ) ;
@@ -827,6 +894,127 @@ test.describe("MIDI Studio V2", () => {
827894 }
828895 } ) ;
829896
897+ test ( "canvas note editing flow supports hover click drag paint erase and playback" , async ( { page } ) => {
898+ const server = await openMidiStudioForImport ( page ) ;
899+ try {
900+ await page . locator ( "#toolImportManifestInput" ) . setInputFiles ( uatManifestPath ) ;
901+ await selectMidiStudioTab ( page , "instruments" ) ;
902+ await selectInstrumentRow ( page , "lead" ) ;
903+ await selectMidiStudioTab ( page , "studio" ) ;
904+ await waitForCanvasRender ( page ) ;
905+ await expect ( octaveTimelineCanvas ( page ) ) . toBeVisible ( ) ;
906+ await expect ( page . locator ( ".midi-studio-v2__octave-note-cell" ) ) . toHaveCount ( 0 ) ;
907+
908+ const target = await emptyCanvasRun ( page , { lane : "lead" , length : 4 } ) ;
909+ expect ( target ) . toBeTruthy ( ) ;
910+ const hoverBefore = await canvasCellPixel ( page , target . rowToken , target . stepIndex ) ;
911+ await hoverCanvasCell ( page , target . rowToken , target . stepIndex ) ;
912+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-hover-row-token" , target . rowToken ) ;
913+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-hover-step-index" , String ( target . stepIndex ) ) ;
914+ await expect . poll ( ( ) => canvasTimelineState ( page ) . then ( ( state ) => state . hoverCell ) ) . toEqual ( {
915+ rowToken : target . rowToken ,
916+ stepIndex : target . stepIndex
917+ } ) ;
918+ const hoverAfter = await canvasCellPixel ( page , target . rowToken , target . stepIndex ) ;
919+ expect ( hoverAfter ) . not . toEqual ( hoverBefore ) ;
920+
921+ await page . evaluate ( ( ) => {
922+ window . __midiStudioPreviewSynthEvents = [ ] ;
923+ } ) ;
924+ await clickCanvasCell ( page , target . rowToken , target . stepIndex ) ;
925+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K P a i n t e d .* f o r L e a d a c r o s s t h e t i m e l i n e ; p l a y b a c k d a t a u p d a t e d \. / ) ;
926+ expect ( await hasCanvasNote ( page , "lead" , target . rowToken , target . stepIndex ) ) . toBe ( true ) ;
927+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-selected-row-token" , target . rowToken ) ;
928+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-selected-step-index" , String ( target . stepIndex ) ) ;
929+ await expect ( page . locator ( "#timelineSelectionDetails" ) ) . toContainText ( "Selected cell" ) ;
930+ await expect ( page . locator ( "#timelineSelectionDetails" ) ) . toContainText ( `${ target . rowToken } / step ${ target . stepIndex + 1 } ` ) ;
931+ await expect . poll ( ( ) => page . evaluate ( ( ) => window . __midiStudioPreviewSynthEvents . some ( ( event ) => event . action === "oscillator-start" ) ) ) . toBe ( true ) ;
932+
933+ await clickCanvasCell ( page , target . rowToken , target . stepIndex ) ;
934+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K E r a s e d .* f o r L e a d a c r o s s t h e t i m e l i n e ; p l a y b a c k d a t a u p d a t e d \. / ) ;
935+ expect ( await hasCanvasNote ( page , "lead" , target . rowToken , target . stepIndex ) ) . toBe ( false ) ;
936+
937+ await page . evaluate ( ( ) => {
938+ window . __midiStudioPreviewSynthEvents = [ ] ;
939+ } ) ;
940+ await dragCanvasCells ( page , target . rowToken , target . stepIndex , target . rowToken , target . stepIndex + 2 ) ;
941+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K P a i n t e d .* f o r L e a d a c r o s s t h e t i m e l i n e ; p l a y b a c k d a t a u p d a t e d \. / ) ;
942+ for ( let stepIndex = target . stepIndex ; stepIndex <= target . stepIndex + 2 ; stepIndex += 1 ) {
943+ expect ( await hasCanvasNote ( page , "lead" , target . rowToken , stepIndex ) ) . toBe ( true ) ;
944+ }
945+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-selected-row-token" , target . rowToken ) ;
946+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-selected-step-index" , String ( target . stepIndex + 2 ) ) ;
947+ await expect . poll ( ( ) => page . evaluate ( ( ) => window . __midiStudioPreviewSynthEvents . some ( ( event ) => event . action === "oscillator-start" ) ) ) . toBe ( true ) ;
948+
949+ const canonicalPainted = await page . evaluate ( ( paintTarget ) => {
950+ const app = window . __midiStudioV2App ;
951+ return [ paintTarget . stepIndex , paintTarget . stepIndex + 1 , paintTarget . stepIndex + 2 ] . map ( ( stepIndex ) => ( {
952+ stepIndex,
953+ token : app . instrumentGrid . tokenForLaneStep ( "lead" , stepIndex ) ,
954+ visibleInGrid : app . lastInstrumentGridResult . timeline . some ( ( event ) => event . lane === "lead"
955+ && event . stepIndex === stepIndex
956+ && app . instrumentGrid . rowsForEvent ( event ) . includes ( paintTarget . rowToken ) )
957+ } ) ) ;
958+ } , target ) ;
959+ expect ( canonicalPainted . every ( ( entry ) => entry . visibleInGrid && entry . token . includes ( target . rowToken ) ) ) . toBe ( true ) ;
960+
961+ await page . evaluate ( ( paintTarget ) => {
962+ const app = window . __midiStudioV2App ;
963+ const originalPlayGridRange = app . previewSynth . playGridRange . bind ( app . previewSynth ) ;
964+ app . __lastPreviewGridValues = [ ] ;
965+ app . previewSynth . playGridRange = async ( options ) => {
966+ app . __lastPreviewGridValues = options . grid . timeline
967+ . filter ( ( event ) => event . lane === "lead"
968+ && event . stepIndex >= paintTarget . stepIndex
969+ && event . stepIndex <= paintTarget . stepIndex + 2 )
970+ . map ( ( event ) => event . value ) ;
971+ return originalPlayGridRange ( options ) ;
972+ } ;
973+ window . __midiStudioPreviewSynthEvents = [ ] ;
974+ } , target ) ;
975+ await page . locator ( "#playButton" ) . click ( ) ;
976+ await expect ( page . locator ( "#stopButton" ) ) . toBeEnabled ( ) ;
977+ await expect ( page . locator ( "#playButton" ) ) . toBeDisabled ( ) ;
978+ await expect . poll ( ( ) => page . evaluate ( ( ) => window . __midiStudioV2App . __lastPreviewGridValues ) ) . toContain ( target . rowToken ) ;
979+ await expect . poll ( ( ) => page . evaluate ( ( ) => window . __midiStudioPreviewSynthEvents . some ( ( event ) => event . action === "oscillator-start" ) ) ) . toBe ( true ) ;
980+ await page . locator ( "#stopButton" ) . click ( ) ;
981+ await expect ( page . locator ( "#stopButton" ) ) . toBeDisabled ( ) ;
982+ await expect ( page . locator ( "#playButton" ) ) . toBeEnabled ( ) ;
983+
984+ await dragCanvasCells ( page , target . rowToken , target . stepIndex , target . rowToken , target . stepIndex + 2 ) ;
985+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K E r a s e d .* f o r L e a d a c r o s s t h e t i m e l i n e ; p l a y b a c k d a t a u p d a t e d \. / ) ;
986+ for ( let stepIndex = target . stepIndex ; stepIndex <= target . stepIndex + 2 ; stepIndex += 1 ) {
987+ expect ( await hasCanvasNote ( page , "lead" , target . rowToken , stepIndex ) ) . toBe ( false ) ;
988+ }
989+ } finally {
990+ await workspaceV2CoverageReporter . stop ( page ) ;
991+ await server . close ( ) ;
992+ }
993+ } ) ;
994+
995+ test ( "canvas note editing warns when note audition audio is unavailable without blocking edits" , async ( { page } ) => {
996+ const server = await openMidiStudioForImport ( page , { webAudio : false } ) ;
997+ try {
998+ await page . locator ( "#toolImportManifestInput" ) . setInputFiles ( uatManifestPath ) ;
999+ await selectMidiStudioTab ( page , "instruments" ) ;
1000+ await selectInstrumentRow ( page , "lead" ) ;
1001+ await selectMidiStudioTab ( page , "studio" ) ;
1002+ await waitForCanvasRender ( page ) ;
1003+
1004+ const target = await emptyCanvasRun ( page , { lane : "lead" , length : 2 } ) ;
1005+ expect ( target ) . toBeTruthy ( ) ;
1006+ await clickCanvasCell ( page , target . rowToken , target . stepIndex ) ;
1007+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / O K P a i n t e d .* f o r L e a d a c r o s s t h e t i m e l i n e ; p l a y b a c k d a t a u p d a t e d \. / ) ;
1008+ await expect ( page . locator ( "#statusLog" ) ) . toHaveValue ( / W A R N P r e v i e w S y n t h n o t e a u d i t i o n u n a v a i l a b l e : P r e v i e w S y n t h a u d i o u n a v a i l a b l e : W e b A u d i o A u d i o C o n t e x t i s n o t a v a i l a b l e \. U s e a b r o w s e r w i t h W e b A u d i o s u p p o r t \. E d i t i n g w a s k e p t \. / ) ;
1009+ expect ( await hasCanvasNote ( page , "lead" , target . rowToken , target . stepIndex ) ) . toBe ( true ) ;
1010+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-selected-row-token" , target . rowToken ) ;
1011+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-selected-step-index" , String ( target . stepIndex ) ) ;
1012+ } finally {
1013+ await workspaceV2CoverageReporter . stop ( page ) ;
1014+ await server . close ( ) ;
1015+ }
1016+ } ) ;
1017+
8301018 test ( "octave grid density supports icon controls and simultaneous chord editing" , async ( { page } ) => {
8311019 const server = await openMidiStudioForImport ( page ) ;
8321020 try {
0 commit comments