11import { afterEach , describe , expect , it , vi } from 'vitest' ;
22import type { LJStateMachine , LJStateMachineErrorContext } from '../types/fsm' ;
33import { createMermaidDiagram , renderMermaidDiagram } from './diagram' ;
4- import { getConstructorFixStates , getRelatedStates , getStateErrorFocus , getStateErrorFocuses , getStateFixes , orderStateMachines } from './diagram-focus' ;
4+ import { getRelatedStates , getStateErrorFocus , getStateErrorFocuses , orderStateMachines } from './diagram-focus' ;
55
66// DefaultMutableTreeNode from the state error focus issue:
77// root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error
@@ -62,10 +62,10 @@ function lines(diagram: string): string[] {
6262}
6363
6464const dimmedTransitions = ( diagram : string ) => lines ( diagram ) . filter ( line => line . startsWith ( '%% lj-dimmed-transitions:' ) ) ;
65- const fixTransitions = ( diagram : string ) => lines ( diagram ) . filter ( line => line . startsWith ( '%% lj-fix-transitions:' ) ) ;
6665const calledMethod = ( label : string ) => `<span class="lj-state-error-method">${ label } </span>` ;
6766const dimmedMethod = ( label : string ) => `<span class="lj-dimmed-method">${ label } </span>` ;
68- const collapsedMethods = ( names : string [ ] , count : string ) => `<span class="lj-dimmed-method lj-collapsed-methods" title='${ names . join ( ', ' ) } '>${ count } </span>` ;
67+ const collapsedMethods = ( labels : string [ ] , count : string ) =>
68+ `<span class="lj-dimmed-method lj-collapsed-methods">${ count } <span class="lj-collapsed-list">${ labels . map ( label => `<span class="lj-collapsed-item">${ label } </span>` ) . join ( '' ) } </span></span>` ;
6969
7070describe ( 'state error focus' , ( ) => {
7171 it ( 'splits the possible states into valid and invalid ones' , ( ) => {
@@ -103,14 +103,13 @@ describe('state error focus', () => {
103103 }
104104 } ) ;
105105
106- it ( 'dims the states unrelated to the error and the transitions that do not help to fix it when focused' , ( ) => {
106+ it ( 'dims the states unrelated to the error and the transitions without the failed method when focused' , ( ) => {
107107 const focused = createMermaidDiagram ( datagramSocket ( ) , 'TB' , false , true ) ;
108108
109109 expect ( lines ( focused ) ) . toContain ( 'class broken ljStateDimmed' ) ;
110- // the constructor without arguments is the way to fix closed, which no call leaves
111- expect ( fixTransitions ( focused ) ) . toEqual ( [ '%% lj-fix-transitions: edge0' ] ) ;
112- // the other constructor, then bind, connect, disconnect, every close, repair, and discard
113- expect ( dimmedTransitions ( focused ) ) . toEqual ( [ '%% lj-dimmed-transitions: edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12' ] ) ;
110+ // both constructors, then bind, connect, disconnect, every close, repair, and discard
111+ expect ( dimmedTransitions ( focused ) ) . toEqual ( [ '%% lj-dimmed-transitions: edge0 edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12' ] ) ;
112+ expect ( focused ) . not . toContain ( 'lj-fix' ) ;
114113 } ) ;
115114
116115 it ( 'draws entry transitions from the initial pseudo state, dimmed when focused' , ( ) => {
@@ -130,8 +129,10 @@ describe('state error focus', () => {
130129 { to : 'maybeHasChildren' , label : 'getChildCount' , toCondition : 'return > 0' } ,
131130 ] } ;
132131
133- expect ( lines ( createMermaidDiagram ( sm , 'TB' , true , true ) ) )
134- . toContain ( `[*] --> maybeHasChildren : ${ collapsedMethods ( [ 'isLeaf' , 'getChildCount' ] , '2 methods' ) } ` ) ;
132+ expect ( lines ( createMermaidDiagram ( sm , 'TB' , true , true ) ) ) . toContain ( `[*] --> maybeHasChildren : ${ collapsedMethods ( [
133+ 'isLeaf<br/><span class="state-cond state-cond-post">!return</span>' ,
134+ 'getChildCount<br/><span class="state-cond state-cond-post">return > 0</span>' ,
135+ ] , '2 methods' ) } `) ;
135136 } ) ;
136137
137138 it ( 'leaves the full diagram unchanged when showing all' , ( ) => {
@@ -145,7 +146,7 @@ describe('state error focus', () => {
145146 ] ) ) ;
146147 } ) ;
147148
148- it ( 'collapses the dimmed methods of a label into a count, named in its tooltip ' , ( ) => {
149+ it ( 'collapses the dimmed methods of a label into a count, keeping them for the list on hover ' , ( ) => {
149150 const sm = datagramSocket ( ) ;
150151 sm . transitions . push (
151152 { from : 'bound' , to : 'bound' , label : 'getPort' } ,
@@ -170,45 +171,6 @@ describe('state error focus', () => {
170171 expect ( diagram . filter ( line => line . startsWith ( '[*]' ) ) ) . toHaveLength ( 1 ) ;
171172 } ) ;
172173
173- it ( 'keeps a shortest way from each invalid state to an accepted state bright' , ( ) => {
174- const sm = datagramSocket ( ) ;
175- sm . transitions . push (
176- { from : 'closed' , to : 'broken' , label : 'fail' } ,
177- { from : 'broken' , to : 'unbound' , label : 'reset' } ,
178- { from : 'broken' , to : 'unbound' , label : 'restart' } ,
179- ) ;
180-
181- const diagram = createMermaidDiagram ( sm , 'TB' , false , true ) ;
182- expect ( lines ( diagram ) ) . toEqual ( expect . arrayContaining ( [
183- 'closed --> broken : fail' ,
184- // both methods of a step are a way
185- 'broken --> unbound : reset<br/>restart' ,
186- // a constructor is no fix when the invalid state can reach an accepted one
187- `[*] --> bound : ${ dimmedMethod ( 'new DatagramSocket()' ) } ` ,
188- ] ) ) ;
189- expect ( fixTransitions ( diagram ) ) . toEqual ( [ '%% lj-fix-transitions: edge13 edge14' ] ) ;
190- } ) ;
191-
192- it ( 'finds a shortest way to an accepted state for each invalid state, or none' , ( ) => {
193- const sm = datagramSocket ( { possibleStates : [ 'closed' , 'broken' ] , validStates : [ ] , invalidStates : [ 'closed' , 'broken' ] } ) ;
194- const focus = getStateErrorFocus ( sm ) ! ;
195-
196- const fixes = getStateFixes ( sm , focus ) ;
197- expect ( fixes ) . toEqual ( [
198- { from : 'closed' , steps : [ ] } ,
199- { from : 'broken' , steps : [ ] } ,
200- ] ) ;
201- expect ( getConstructorFixStates ( sm , focus , fixes ) ) . toEqual ( [ 'bound' ] ) ;
202-
203- sm . transitions . push ( { from : 'closed' , to : 'unbound' , label : 'reopen' } , { from : 'closed' , to : 'broken' , label : 'fail' } ) ;
204- expect ( getStateFixes ( sm , focus ) ) . toEqual ( [
205- { from : 'closed' , steps : [ { from : 'closed' , to : 'unbound' , methods : [ 'reopen' ] } ] } ,
206- // through closed, which the call does not accept either
207- { from : 'broken' , steps : [ { from : 'broken' , to : 'closed' , methods : [ 'discard' ] } , { from : 'closed' , to : 'unbound' , methods : [ 'reopen' ] } ] } ,
208- ] ) ;
209- expect ( getConstructorFixStates ( sm , focus , getStateFixes ( sm , focus ) ) ) . toEqual ( [ ] ) ;
210- } ) ;
211-
212174 it ( 'dims the conditions of a dimmed method with it' , ( ) => {
213175 const sm = datagramSocket ( ) ;
214176 sm . transitions [ 0 ] = { ...sm . transitions [ 0 ] , fromCondition : 'unbound(this)' } ;
@@ -246,8 +208,7 @@ describe('state error focus', () => {
246208 const diagram = createMermaidDiagram ( sm , 'TB' , false , true ) ;
247209 expect ( lines ( diagram ) ) . toContain ( 'class unbound,bound,connected ljStateAccepted' ) ;
248210 expect ( diagram ) . not . toMatch ( / l j S t a t e V a l i d | l j S t a t e I n v a l i d | l j S t a t e D i m m e d | " a s / ) ;
249- // no state is known to reject the call, so there is no way to mark and only the failed method stays bright
250- expect ( fixTransitions ( diagram ) ) . toEqual ( [ ] ) ;
211+ // only the failed method stays bright
251212 expect ( lines ( diagram ) ) . toEqual ( expect . arrayContaining ( [ `unbound --> bound : ${ dimmedMethod ( 'bind' ) } ` , `broken --> broken : ${ dimmedMethod ( 'repair' ) } ` ] ) ) ;
252213 } ) ;
253214
@@ -259,14 +220,12 @@ describe('state error focus', () => {
259220 'class childrenAllowed ljStateInvalid' ,
260221 'class maybeHasChildren ljStateAccepted' ,
261222 `maybeHasChildren --> maybeHasChildren : ${ calledMethod ( 'getFirstChild' ) } ` ,
262- // add is the way to the accepted state
263- 'childrenAllowed --> maybeHasChildren : add' ,
223+ `childrenAllowed --> maybeHasChildren : ${ dimmedMethod ( 'add' ) } ` ,
264224 `maybeHasChildren --> childrenAllowed : ${ dimmedMethod ( 'removeAllChildren' ) } ` ,
265225 ] ) ) ;
266226 expect ( diagram . join ( '\n' ) ) . not . toMatch ( / m a y b e H a s C h i l d r e n " a s | l j S t a t e V a l i d | l j S t a t e D i m m e d / ) ;
267227 // the constructor is dimmed although it leads to a possible state
268- expect ( dimmedTransitions ( diagram . join ( '\n' ) ) ) . toEqual ( [ '%% lj-dimmed-transitions: edge0 edge2' ] ) ;
269- expect ( fixTransitions ( diagram . join ( '\n' ) ) ) . toEqual ( [ '%% lj-fix-transitions: edge1' ] ) ;
228+ expect ( dimmedTransitions ( diagram . join ( '\n' ) ) ) . toEqual ( [ '%% lj-dimmed-transitions: edge0 edge1 edge2' ] ) ;
270229 } ) ;
271230
272231 it ( 'only highlights the possible states when the accepted states are unknown' , ( ) => {
@@ -297,7 +256,7 @@ describe('rendered state error focus', () => {
297256 document . body . innerHTML = '' ;
298257 } ) ;
299258
300- it ( 'dims the rendered path and label of the listed transitions, and marks the ways to fix the error ' , async ( ) => {
259+ it ( 'dims the rendered path and label of the listed transitions' , async ( ) => {
301260 const diagram = createMermaidDiagram ( datagramSocket ( ) , 'TB' , false , true ) ;
302261 document . body . innerHTML = '<div class="diagram-container"><div id="diagram-wrapper"><pre class="mermaid"></pre></div></div>' ;
303262 const pre = document . querySelector ( '.mermaid' ) ! ;
@@ -311,9 +270,81 @@ describe('rendered state error focus', () => {
311270 await renderMermaidDiagram ( document , { mermaid : { run } } ) ;
312271
313272 const dimmed = ( selector : string ) => Array . from ( document . querySelectorAll ( selector ) , node => node . getAttribute ( 'data-id' ) ) ;
314- expect ( dimmed ( 'path.transition.lj-dimmed' ) ) . toEqual ( [ 'edge1' , 'edge12' ] ) ;
315- expect ( dimmed ( 'g.label.lj-dimmed' ) ) . toEqual ( [ 'edge1' , 'edge12' ] ) ;
316- expect ( dimmed ( 'path.transition.lj-fix-transition' ) ) . toEqual ( [ 'edge0' ] ) ;
273+ expect ( dimmed ( 'path.transition.lj-dimmed' ) ) . toEqual ( [ 'edge0' , 'edge1' , 'edge12' ] ) ;
274+ expect ( dimmed ( 'g.label.lj-dimmed' ) ) . toEqual ( [ 'edge0' , 'edge1' , 'edge12' ] ) ;
275+ } ) ;
276+ } ) ;
277+
278+ describe ( 'collapsed labels' , ( ) => {
279+ afterEach ( ( ) => {
280+ document . body . innerHTML = '' ;
281+ } ) ;
282+
283+ async function render ( ) {
284+ const sm = datagramSocket ( ) ;
285+ sm . transitions . push ( { from : 'bound' , to : 'bound' , label : 'getPort' } ) ;
286+ const label = lines ( createMermaidDiagram ( sm , 'TB' , false , true ) ) . find ( line => line . startsWith ( 'bound --> bound' ) ) ! . split ( ' : ' ) [ 1 ] ;
287+ document . body . innerHTML = '<div class="diagram-container"><div id="diagram-wrapper"><pre class="mermaid">x</pre></div></div>' ;
288+ const pre = document . querySelector ( '.mermaid' ) ! ;
289+ const run = vi . fn ( async ( ) => {
290+ pre . innerHTML = `<svg><g class="edgeLabel"><foreignObject><div>${ label } </div></foreignObject></g></svg>` ;
291+ } ) ;
292+ await renderMermaidDiagram ( document , { mermaid : { run } } ) ;
293+ return document . querySelector ( '.lj-collapsed-methods' ) ! ;
294+ }
295+ const popover = ( ) => document . querySelector ( '.lj-methods-popover' ) ;
296+ const items = ( ) => Array . from ( document . querySelectorAll ( '.lj-methods-popover .lj-collapsed-item' ) , node => node . textContent ) ;
297+
298+ it ( 'lists the methods on hover without rendering the diagram again' , async ( ) => {
299+ const label = await render ( ) ;
300+ expect ( popover ( ) ) . toBeNull ( ) ;
301+
302+ label . dispatchEvent ( new MouseEvent ( 'mouseenter' ) ) ;
303+ expect ( items ( ) ) . toEqual ( [ 'send' , 'getPort' ] ) ;
304+ label . dispatchEvent ( new MouseEvent ( 'mouseleave' ) ) ;
305+ expect ( popover ( ) ) . toBeNull ( ) ;
306+ } ) ;
307+
308+ it ( 'places the list beside the label, off the states' , async ( ) => {
309+ const label = await render ( ) ;
310+ const box = ( left : number , top : number , width : number , height : number ) => ( { left, top, right : left + width , bottom : top + height } ) as DOMRect ;
311+ label . getBoundingClientRect = ( ) => box ( 100 , 100 , 60 , 20 ) ;
312+ // a state right of the label
313+ document . querySelector ( 'svg' ) ! . insertAdjacentHTML ( 'beforeend' , '<g class="node"></g>' ) ;
314+ document . querySelector < Element > ( 'g.node' ) ! . getBoundingClientRect = ( ) => box ( 170 , 50 , 200 , 120 ) ;
315+ const size = ( name : 'offsetWidth' | 'offsetHeight' , value : number ) => vi . spyOn ( HTMLElement . prototype , name , 'get' ) . mockReturnValue ( value ) ;
316+ const spies = [ size ( 'offsetWidth' , 80 ) , size ( 'offsetHeight' , 40 ) ] ;
317+ // a 1000 x 800 page, where fixed elements land where they are put
318+ vi . spyOn ( document . documentElement , 'clientWidth' , 'get' ) . mockReturnValue ( 1000 ) ;
319+ vi . spyOn ( document . documentElement , 'clientHeight' , 'get' ) . mockReturnValue ( 800 ) ;
320+ vi . spyOn ( HTMLDivElement . prototype , 'getBoundingClientRect' ) . mockImplementation ( function ( this : HTMLDivElement ) {
321+ return box ( parseFloat ( this . style . left ) || 0 , parseFloat ( this . style . top ) || 0 , 80 , 40 ) ;
322+ } ) ;
323+
324+ label . dispatchEvent ( new MouseEvent ( 'mouseenter' ) ) ;
325+ expect ( ( popover ( ) as HTMLElement ) . style . left ) . toBe ( `${ 100 - 6 - 80 } px` ) ;
326+ expect ( ( popover ( ) as HTMLElement ) . style . top ) . toBe ( '90px' ) ;
327+ spies . forEach ( spy => spy . mockRestore ( ) ) ;
328+ vi . restoreAllMocks ( ) ;
329+ } ) ;
330+
331+ it ( 'keeps the list after a click until the next click, a pan, or a zoom' , async ( ) => {
332+ const label = await render ( ) ;
333+
334+ label . dispatchEvent ( new MouseEvent ( 'mouseenter' ) ) ;
335+ label . dispatchEvent ( new MouseEvent ( 'click' , { bubbles : true } ) ) ;
336+ label . dispatchEvent ( new MouseEvent ( 'mouseleave' ) ) ;
337+ expect ( items ( ) ) . toEqual ( [ 'send' , 'getPort' ] ) ;
338+ label . dispatchEvent ( new MouseEvent ( 'click' , { bubbles : true } ) ) ;
339+ expect ( popover ( ) ) . toBeNull ( ) ;
340+
341+ label . dispatchEvent ( new MouseEvent ( 'click' , { bubbles : true } ) ) ;
342+ document . querySelector ( 'svg' ) ! . dispatchEvent ( new MouseEvent ( 'mousedown' , { bubbles : true } ) ) ;
343+ expect ( popover ( ) ) . toBeNull ( ) ;
344+
345+ label . dispatchEvent ( new MouseEvent ( 'click' , { bubbles : true } ) ) ;
346+ document . querySelector ( '.diagram-container' ) ! . dispatchEvent ( new WheelEvent ( 'wheel' ) ) ;
347+ expect ( popover ( ) ) . toBeNull ( ) ;
317348 } ) ;
318349} ) ;
319350
0 commit comments