Skip to content

Commit 782e902

Browse files
Drop the diagram's ways to fix and list collapsed methods on hover
The shortest way along the diagram ignored the transitions' conditions and the code, and fell back to "create a new object" for a dead-end state. Only the failed method stays bright now; the verifier's hints are the ways to fix. Hovering "+N more" or "N methods" lists the methods, with their conditions, next to the label, and a click keeps the list until the next click, a pan, or a zoom. The diagram is not laid out again, so the list shows at once. The list goes right, left, above, or below the label, or beside the diagram, wherever it covers the fewest states, labels, and transition lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent eca3f3e commit 782e902

8 files changed

Lines changed: 369 additions & 194 deletions

File tree

‎client/src/webview/diagram-focus.spec.ts‎

Lines changed: 91 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
22
import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm';
33
import { 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

6464
const 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:'));
6665
const calledMethod = (label: string) => `<span class="lj-state-error-method">${label}</span>`;
6766
const 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

7070
describe('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 &gt; 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(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /);
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(/maybeHasChildren" as|ljStateValid|ljStateDimmed/);
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

‎client/src/webview/diagram-focus.ts‎

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -79,77 +79,3 @@ export function orderStateMachines(sms: LJStateMachine[]): LJStateMachine[] {
7979
export function getStateErrorFocuses(sms: LJStateMachine[]): StateErrorFocus[] {
8080
return sms.map(getStateErrorFocus).filter((focus): focus is StateErrorFocus => !!focus);
8181
}
82-
83-
/**
84-
* A transition of a way to fix a state error, with every method that makes it
85-
*/
86-
export type StateFixStep = {
87-
from: string;
88-
to: string;
89-
methods: string[];
90-
};
91-
92-
/**
93-
* A way to bring the receiver from a state that rejects the call to a state that accepts it, read from the diagram
94-
* The steps are empty when no transition leads from the state to an accepted one
95-
*/
96-
export type StateFix = {
97-
from: string;
98-
steps: StateFixStep[];
99-
};
100-
101-
/**
102-
* Gets, for each possible state that rejects the call, a shortest way along the diagram's transitions to an accepted state
103-
* The diagram only has the transitions of the specification, so a way may need specific arguments, and other ways may exist
104-
* @param sm the state machine
105-
* @param focus the state error on it
106-
* @returns one fix per invalid state, in the order of the states
107-
*/
108-
export function getStateFixes(sm: LJStateMachine, focus: StateErrorFocus): StateFix[] {
109-
const methodsByEdge = new Map<string, string[]>();
110-
sm.transitions.forEach(({ from, to, label }) => {
111-
if (from === to) return;
112-
const key = `${from}|${to}`;
113-
const methods = methodsByEdge.get(key) ?? [];
114-
if (!methods.includes(label)) methods.push(label);
115-
methodsByEdge.set(key, methods);
116-
});
117-
const edges = [...methodsByEdge].map(([key, methods]) => {
118-
const [from, to] = key.split('|');
119-
return { from, to, methods };
120-
});
121-
122-
return sm.states.filter(state => focus.invalidStates.includes(state)).map(start => {
123-
// breadth-first, so the first accepted state reached is one of the closest
124-
const previous = new Map<string, StateFixStep | null>([[start, null]]);
125-
const queue = [start];
126-
while (queue.length > 0) {
127-
const state = queue.shift()!;
128-
if (focus.acceptedStates.includes(state)) return { from: start, steps: getStepsTo(state, previous) };
129-
edges.filter(edge => edge.from === state && !previous.has(edge.to)).forEach(edge => {
130-
previous.set(edge.to, edge);
131-
queue.push(edge.to);
132-
});
133-
}
134-
return { from: start, steps: [] };
135-
});
136-
}
137-
138-
function getStepsTo(state: string, previous: Map<string, StateFixStep | null>): StateFixStep[] {
139-
const steps: StateFixStep[] = [];
140-
for (let step = previous.get(state); step; step = previous.get(step.from)) steps.unshift(step);
141-
return steps;
142-
}
143-
144-
/**
145-
* Gets the states whose constructors fix a state error, which are the accepted states when an invalid state cannot reach one
146-
* @param sm the state machine
147-
* @param focus the state error on it
148-
* @param fixes the fixes of the state error
149-
* @returns the accepted states a constructor leads to, or none when every invalid state can reach an accepted one
150-
*/
151-
export function getConstructorFixStates(sm: LJStateMachine, focus: StateErrorFocus, fixes: StateFix[]): string[] {
152-
if (!fixes.some(fix => fix.steps.length === 0)) return [];
153-
return [...new Set(sm.initialTransitions.map(transition => transition.to))]
154-
.filter(state => focus.acceptedStates.includes(state));
155-
}

0 commit comments

Comments
 (0)