@@ -403,6 +403,35 @@ function octaveNoteBlock(page, lane) {
403403 return page . locator ( `.midi-studio-v2__octave-note-cell[data-note-lanes~="${ lane } "]` ) ;
404404}
405405
406+ function octaveTimelineCanvas ( page ) {
407+ return page . locator ( "[data-octave-timeline-canvas='true']" ) ;
408+ }
409+
410+ async function canvasTimelineState ( page ) {
411+ return page . evaluate ( ( ) => window . __midiStudioV2App . instrumentGrid . timelineCanvasState ( ) ) ;
412+ }
413+
414+ async function waitForCanvasRender ( page ) {
415+ await page . waitForFunction ( ( ) => Number ( document . querySelector ( "[data-octave-timeline-canvas='true']" ) ?. dataset . renderFrame || 0 ) > 0 ) ;
416+ }
417+
418+ async function scrollCanvasCellIntoView ( page , rowToken , stepIndex ) {
419+ await page . locator ( "#instrumentGridOutput" ) . evaluate ( ( output , target ) => {
420+ const state = window . __midiStudioV2App . instrumentGrid . timelineCanvasState ( ) ;
421+ const rowIndex = state . rows . findIndex ( ( row ) => row . value === target . rowToken ) ;
422+ output . scrollLeft = Math . max ( 0 , state . axisWidth + target . stepIndex * state . cellSize - output . clientWidth / 2 ) ;
423+ output . scrollTop = Math . max ( 0 , state . headerHeight + rowIndex * state . cellSize - output . clientHeight / 2 ) ;
424+ output . dispatchEvent ( new Event ( "scroll" ) ) ;
425+ } , { rowToken, stepIndex } ) ;
426+ }
427+
428+ async function clickCanvasCell ( page , rowToken , stepIndex ) {
429+ await scrollCanvasCellIntoView ( page , rowToken , stepIndex ) ;
430+ const point = await page . evaluate ( ( target ) => window . __midiStudioV2App . instrumentGrid . timelineCanvasCellCenter ( target . rowToken , target . stepIndex ) , { rowToken, stepIndex } ) ;
431+ expect ( point ) . toBeTruthy ( ) ;
432+ await page . mouse . click ( point . x , point . y ) ;
433+ }
434+
406435function instrumentRow ( page , lane ) {
407436 return page . locator ( `.midi-studio-v2__instrument-row[data-lane="${ lane } "]` ) ;
408437}
@@ -635,6 +664,136 @@ test.describe("MIDI Studio V2", () => {
635664 }
636665 } ) ;
637666
667+ test ( "canvas octave timeline edits canonical data and drives playback without DOM grid repaint" , async ( { page } ) => {
668+ const server = await openMidiStudioForImport ( page ) ;
669+ try {
670+ await page . locator ( "#toolImportManifestInput" ) . setInputFiles ( uatManifestPath ) ;
671+ await expect ( page . locator ( '[data-midi-studio-tab="studio"]' ) ) . toHaveAttribute ( "aria-selected" , "true" ) ;
672+ await expect ( page . locator ( "#instrumentGridHeading" ) ) . toHaveText ( "Octave Timeline" ) ;
673+ await expect ( page . locator ( "#instrumentGridOutput" ) ) . toHaveAttribute ( "data-timeline-renderer" , "canvas" ) ;
674+ await expect ( octaveTimelineCanvas ( page ) ) . toBeVisible ( ) ;
675+ await expect ( page . locator ( ".midi-studio-v2__octave-note-cell" ) ) . toHaveCount ( 0 ) ;
676+ await expect ( page . locator ( ".midi-studio-v2__grid-cell--timing-header" ) ) . toHaveCount ( 0 ) ;
677+ await expect ( instrumentTypeSelect ( page , "lead" ) ) . toHaveJSProperty ( "tagName" , "SELECT" ) ;
678+ await expect ( instrumentSelect ( page , "lead" ) ) . toHaveJSProperty ( "tagName" , "SELECT" ) ;
679+ await expect ( page . locator ( "#playButton" ) ) . toHaveJSProperty ( "tagName" , "BUTTON" ) ;
680+ await expect ( page . locator ( "#toolImportManifestInput" ) ) . toHaveJSProperty ( "tagName" , "INPUT" ) ;
681+
682+ await selectInstrumentRow ( page , "lead" ) ;
683+ await waitForCanvasRender ( page ) ;
684+ const initialCanvasState = await canvasTimelineState ( page ) ;
685+ expect ( initialCanvasState . rows . some ( ( row ) => row . value === "C6" ) ) . toBe ( true ) ;
686+ expect ( initialCanvasState . rows . some ( ( row ) => row . keyKind === "black" ) ) . toBe ( true ) ;
687+ expect ( initialCanvasState . rows . some ( ( row ) => row . keyKind === "white" ) ) . toBe ( true ) ;
688+ expect ( initialCanvasState . totalSteps ) . toBeGreaterThan ( 0 ) ;
689+ expect ( initialCanvasState . noteCount ) . toBeGreaterThan ( 0 ) ;
690+
691+ const keyboardAxisPixel = await page . evaluate ( ( ) => {
692+ const canvas = document . querySelector ( "[data-octave-timeline-canvas='true']" ) ;
693+ const state = window . __midiStudioV2App . instrumentGrid . timelineCanvasState ( ) ;
694+ const rowIndex = state . rows . findIndex ( ( row ) => row . value === "C6" ) ;
695+ const ratio = canvas . width / canvas . clientWidth ;
696+ const sample = canvas . getContext ( "2d" ) . getImageData (
697+ Math . round ( 10 * ratio ) ,
698+ Math . round ( ( state . headerHeight + rowIndex * state . cellSize + state . cellSize / 2 ) * ratio ) ,
699+ 1 ,
700+ 1
701+ ) . data ;
702+ return Array . from ( sample ) ;
703+ } ) ;
704+ expect ( keyboardAxisPixel [ 3 ] ) . toBeGreaterThan ( 0 ) ;
705+ expect ( keyboardAxisPixel . slice ( 0 , 3 ) . some ( ( channel ) => channel > 0 ) ) . toBe ( true ) ;
706+
707+ await clickCanvasCell ( page , "C6" , 2 ) ;
708+ 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 \. / ) ;
709+ const canonicalEdit = await page . evaluate ( ( ) => {
710+ const app = window . __midiStudioV2App ;
711+ return {
712+ gridHasEdit : app . lastInstrumentGridResult . timeline . some ( ( event ) => event . lane === "lead" && event . stepIndex === 2 && event . value === "C6" ) ,
713+ leadLane : app . selectedSong ( ) . studioArrangement . lanes . lead ,
714+ selectedCell : app . instrumentGrid . timelineCanvasState ( ) . selectedCell
715+ } ;
716+ } ) ;
717+ expect ( canonicalEdit . gridHasEdit ) . toBe ( true ) ;
718+ expect ( canonicalEdit . leadLane ) . toContain ( "C6" ) ;
719+ expect ( canonicalEdit . selectedCell ) . toEqual ( { rowToken : "C6" , stepIndex : 2 } ) ;
720+
721+ await page . locator ( "#instrumentGridOutput" ) . evaluate ( ( output ) => {
722+ window . __midiStudioGridClassMutations = [ ] ;
723+ window . __midiStudioGridClassObserver = new MutationObserver ( ( records ) => {
724+ records . forEach ( ( record ) => {
725+ window . __midiStudioGridClassMutations . push ( record . target . className || "" ) ;
726+ } ) ;
727+ } ) ;
728+ window . __midiStudioGridClassObserver . observe ( output , {
729+ attributeFilter : [ "class" ] ,
730+ attributes : true ,
731+ subtree : true
732+ } ) ;
733+ } ) ;
734+ await page . evaluate ( ( ) => {
735+ const app = window . __midiStudioV2App ;
736+ const originalPlayGridRange = app . previewSynth . playGridRange . bind ( app . previewSynth ) ;
737+ app . __lastPreviewGridValues = [ ] ;
738+ app . previewSynth . playGridRange = async ( options ) => {
739+ app . __lastPreviewGridValues = options . grid . timeline
740+ . filter ( ( event ) => event . lane === "lead" )
741+ . map ( ( event ) => event . value ) ;
742+ return originalPlayGridRange ( options ) ;
743+ } ;
744+ window . __midiStudioPreviewSynthEvents = [ ] ;
745+ } ) ;
746+ await page . locator ( "#playButton" ) . click ( ) ;
747+ await expect ( page . locator ( "#stopButton" ) ) . toBeEnabled ( ) ;
748+ await expect ( page . locator ( "#playButton" ) ) . toBeDisabled ( ) ;
749+ expect ( await page . evaluate ( ( ) => window . __midiStudioV2App . __lastPreviewGridValues ) ) . toContain ( "C6" ) ;
750+ expect ( await page . evaluate ( ( ) => window . __midiStudioPreviewSynthEvents . some ( ( event ) => event . action === "oscillator-start" ) ) ) . toBe ( true ) ;
751+ await page . waitForFunction ( ( ) => window . __midiStudioV2App . instrumentGrid . playheadStep > 0 ) ;
752+ const playbackCanvasState = await canvasTimelineState ( page ) ;
753+ expect ( playbackCanvasState . playheadStep ) . toBeGreaterThan ( 0 ) ;
754+ await expect ( octaveTimelineCanvas ( page ) ) . toHaveAttribute ( "data-playhead-step" , String ( playbackCanvasState . playheadStep ) ) ;
755+ await expect ( page . locator ( ".midi-studio-v2__grid-cell--playhead-active" ) ) . toHaveCount ( 0 ) ;
756+ expect ( await page . evaluate ( ( ) => window . __midiStudioGridClassMutations . filter ( ( className ) => String ( className ) . includes ( "midi-studio-v2__grid-cell" ) ) . length ) ) . toBe ( 0 ) ;
757+
758+ const scrollEvidence = await page . locator ( "#instrumentGridOutput" ) . evaluate ( ( output ) => {
759+ output . style . width = "320px" ;
760+ output . style . maxWidth = "320px" ;
761+ output . scrollLeft = 240 ;
762+ output . scrollTop = 190 ;
763+ output . dispatchEvent ( new Event ( "scroll" ) ) ;
764+ const topScrollbar = output . querySelector ( ".midi-studio-v2__timeline-scroll-proxy" ) ;
765+ return {
766+ canScrollHorizontal : output . scrollWidth > output . clientWidth ,
767+ canScrollVertical : output . scrollHeight > output . clientHeight ,
768+ datasetScrollLeft : output . dataset . timelineScrollLeft ,
769+ scrollLeft : Math . round ( output . scrollLeft ) ,
770+ scrollTop : Math . round ( output . scrollTop ) ,
771+ topScrollLeft : Math . round ( topScrollbar ?. scrollLeft || 0 )
772+ } ;
773+ } ) ;
774+ expect ( scrollEvidence . canScrollHorizontal ) . toBe ( true ) ;
775+ expect ( scrollEvidence . canScrollVertical ) . toBe ( true ) ;
776+ expect ( scrollEvidence . scrollLeft ) . toBeGreaterThan ( 0 ) ;
777+ expect ( scrollEvidence . scrollTop ) . toBeGreaterThan ( 0 ) ;
778+ expect ( scrollEvidence . datasetScrollLeft ) . toBe ( String ( scrollEvidence . scrollLeft ) ) ;
779+ expect ( scrollEvidence . topScrollLeft ) . toBe ( scrollEvidence . scrollLeft ) ;
780+
781+ const zoomBefore = ( await canvasTimelineState ( page ) ) . cellSize ;
782+ await page . locator ( "#instrumentGridZoomInButton" ) . click ( ) ;
783+ await expect . poll ( ( ) => canvasTimelineState ( page ) . then ( ( state ) => state . cellSize ) ) . toBeGreaterThan ( zoomBefore ) ;
784+ await page . locator ( "#instrumentGridZoomOutButton" ) . click ( ) ;
785+ await expect . poll ( ( ) => canvasTimelineState ( page ) . then ( ( state ) => state . cellSize ) ) . toBe ( zoomBefore ) ;
786+
787+ await page . locator ( "#stopButton" ) . click ( ) ;
788+ await expect ( page . locator ( "#stopButton" ) ) . toBeDisabled ( ) ;
789+ await expect ( page . locator ( "#playButton" ) ) . toBeEnabled ( ) ;
790+ await expect ( page . locator ( "#playbackState" ) ) . toContainText ( "Stopped audible preview" ) ;
791+ } finally {
792+ await workspaceV2CoverageReporter . stop ( page ) ;
793+ await server . close ( ) ;
794+ }
795+ } ) ;
796+
638797 test ( "octave grid density supports icon controls and simultaneous chord editing" , async ( { page } ) => {
639798 const server = await openMidiStudioForImport ( page ) ;
640799 try {
0 commit comments