Skip to content

Commit 34d7611

Browse files
Focus the state machine on the rejected call (#158)
## Description Closes #117. When the State Machine view is opened from a state refinement error, it marks the rejected call on the full diagram. There are two sets of states: - **P**, the states the receiver can be in, from Found; - **A**, the states the call accepts, from Expected. The view marks: - **Invalid, P \ A**: red outline and **✕**. The call fails from here. - **Valid, P ∩ A**: green outline and **✓**. The call is fine from here. - **Accepted but not possible here, A \ P**: a thin dashed yellow outline and no text. It hints at where the object could be brought. - **The failed method**: every arrow labelled with it gets a red label, including inside merged labels. The rejected call is never drawn as a new transition. Red and green have a faint fill and yellow is outline only, so a large machine stays calm. The full diagram is always shown. Focus mode dims what does not help with the error: - states outside P ∪ A; - constructors; - every method name that is neither the failed method nor a transition that moves the receiver out of a state in P to a different state. This applies to each name inside merged labels: in `getOption / receive / send / …` only `receive` stays bright. An arrow's path is dimmed only when all of its names are. "Show all" and "Focus on error" switch the dimming off and on and keep pan and zoom. The label text is identical in both modes, so the layout does not move. A one-line legend shows only the marks that are present: "✕ Call rejected", "✓ Call allowed" and "Allowed, but not reachable here". In light themes the dashed outline uses a darker warning colour. One summary line sits above the diagram, e.g. "`socket.receive()` is rejected when `socket` is in state `closed` · allowed in state `bound`". **Edge cases** - When the receiver's state is unknown (every state is possible), only A is marked, and the summary says the state is unknown. - With no known receiver or accepted states, or with an older server, the view behaves as on `main`. **Data** - P is `possibleStates` (liquid-java/liquidjava-fsm#11). With an older server, it falls back to `definiteFoundState` and then `actualStates`. - A is `expectedStates`. - **Dependency:** bumps `liquidjava-fsm` to 0.1.5. - The call is matched by its exact method name from the error context, not by label text. - "Copy Mermaid Source" copies the plain diagram. ## Screenshots These are real verifier output in headless VS Code. **UDP chat client.** The study example `E_OSS_A1_22_UdpChatClient`: `receive` after an `if` that may close the socket. In focus mode the following stay bright: - `receive`; - `close` and `connect` out of `bound`; - the accessors out of `closed`. The accessors stay bright because the DatagramSocket spec writes `from = "closed(this) && connected(old(this))"`, which is drawn as an arrow from `closed` to `connected`. That `old(...)` in a `from` is a spec error, not an extension issue. <img width="720" alt="DatagramSocket focus: only receive, close and connect stay bright" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/udp-focus-methods.png" /> <details><summary>Whole UDP machine: focus in dark and light, and Show all</summary> <img width="560" alt="UDP focus, dark" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/udp-dark.png" /> <img width="560" alt="UDP focus, light" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/udp-light.png" /> <img width="560" alt="UDP Show all" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/udp-show-all.png" /> </details> **DefaultMutableTreeNode** (the issue's example). `getFirstChild()` after `removeAllChildren()`: - `childrenAllowed` is invalid; - `maybeHasChildren` is accepted; - `add`, the way there, stays bright; - `removeAllChildren` is dimmed. <img width="560" alt="Tree focus, dark" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/tree-dark.png" /> <img width="560" alt="Tree focus, light" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/tree-light.png" /> <details><summary>Dimmed unrelated state (simplified demo machine, not a real class)</summary> <img width="560" alt="Dimmed unrelated state" src="https://raw.githubusercontent.com/liquid-java/vscode-liquidjava/pr-screenshots/158/dimming-demo.png" /> </details> ## Tests Run against liquidjava-fsm 0.1.5 and verifier 0.0.35 from Maven Central: - `npm run lint` and `npm run test:typecheck`: clean. - `npm run test:unit`: 75 passed in 7 files. - `npm test` (VS Code): 8 + 1 passing. - Server `mvn test`: 24 passing. - Local smoke test: passed. The new tests cover: - the split into Valid, Invalid and accepted states; - the fallbacks and the unknown-state case; - empty or unknown states; - exact method matching (`receive` does not match `receiveAll`); - red labels inside merged labels; - dimming of rendered paths and labels, per-name dimming inside merged labels, and the rule for which methods count as useful; - the legend; - dimmed constructors; - diagram clipping below the summary when panned; - the toggle keeping zoom; - plain Mermaid copy; - the computed outline styles. Independently reviewed twice; all findings were fixed. ## Known limits - Large machines such as DatagramSocket have long stacked self-loop labels. This already happens on `main`. - #159 touches the same webview files; whichever PR merges second will be rebased. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6b10158 commit 34d7611

11 files changed

Lines changed: 900 additions & 21 deletions

File tree

‎client/src/types/fsm.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,10 @@ export type LJStateMachine = {
1111
export type LJStateMachineErrorContext = {
1212
calledMethod: string | null;
1313
actualStates: string[];
14+
receiver?: string | null;
15+
expectedStates?: string[];
16+
expectedCondition?: string | null;
17+
foundCondition?: string | null;
18+
definiteFoundState?: string | null;
19+
possibleStates?: string[];
1420
};
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm';
3+
import { createMermaidDiagram, renderMermaidDiagram } from './diagram';
4+
import { getRelatedStates, getStateErrorFocus } from './diagram-focus';
5+
6+
// DefaultMutableTreeNode from the state error focus issue:
7+
// root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error
8+
function treeNode(errorContext: Partial<LJStateMachineErrorContext> | null = {}): LJStateMachine {
9+
return {
10+
className: 'DefaultMutableTreeNode',
11+
states: ['childrenAllowed', 'maybeHasChildren'],
12+
initialTransitions: [{ to: 'childrenAllowed', constructorSignature: 'DefaultMutableTreeNode(Object)' }],
13+
transitions: [
14+
{ from: 'childrenAllowed', to: 'maybeHasChildren', label: 'add' },
15+
{ from: 'maybeHasChildren', to: 'childrenAllowed', label: 'removeAllChildren' },
16+
{ from: 'maybeHasChildren', to: 'maybeHasChildren', label: 'getFirstChild' },
17+
],
18+
errorContext: errorContext && {
19+
calledMethod: 'getFirstChild',
20+
actualStates: ['childrenAllowed'],
21+
receiver: 'root',
22+
expectedStates: ['maybeHasChildren'],
23+
definiteFoundState: 'childrenAllowed',
24+
possibleStates: ['childrenAllowed'],
25+
...errorContext,
26+
},
27+
};
28+
}
29+
30+
// java.net.DatagramSocket from the UDP chat client study example: socket.close() on one branch,
31+
// then socket.receive(...), with an extra state unrelated to the error
32+
function datagramSocket(errorContext: Partial<LJStateMachineErrorContext> = {}): LJStateMachine {
33+
const open = ['unbound', 'bound', 'connected'];
34+
return {
35+
className: 'java.net.DatagramSocket',
36+
states: ['unbound', 'bound', 'connected', 'closed', 'broken'],
37+
initialTransitions: [{ to: 'bound', constructorSignature: 'new DatagramSocket()' }, { to: 'broken' }],
38+
transitions: [
39+
{ from: 'unbound', to: 'bound', label: 'bind' },
40+
{ from: 'bound', to: 'connected', label: 'connect' },
41+
{ from: 'connected', to: 'bound', label: 'disconnect' },
42+
...open.map(state => ({ from: state, to: state, label: 'receive' })),
43+
...open.map(state => ({ from: state, to: state, label: 'send' })),
44+
...open.map(state => ({ from: state, to: 'closed', label: 'close' })),
45+
{ from: 'broken', to: 'broken', label: 'repair' },
46+
{ from: 'broken', to: 'closed', label: 'discard' },
47+
],
48+
errorContext: {
49+
calledMethod: 'receive',
50+
actualStates: ['bound', 'closed'],
51+
receiver: 'socket',
52+
expectedStates: open,
53+
expectedCondition: 'unbound(socket) || bound(socket) || connected(socket)',
54+
foundCondition: 'flag ? closed(socket) : bound(socket)',
55+
definiteFoundState: null,
56+
possibleStates: ['bound', 'closed'],
57+
...errorContext,
58+
},
59+
};
60+
}
61+
62+
function lines(diagram: string): string[] {
63+
return diagram.split('\n').map(line => line.trim());
64+
}
65+
66+
const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:'));
67+
const calledMethod = (label: string) => `<span class="lj-state-error-method">${label}</span>`;
68+
const dimmedMethod = (label: string) => `<span class="lj-dimmed-method">${label}</span>`;
69+
// removes the hooks that only style the focused diagram
70+
const withoutDimming = (diagram: string) => lines(diagram.replace(/<span class="lj-dimmed-method">(.*?)<\/span>/g, '$1'))
71+
.filter(line => !line.startsWith('%%') && !line.endsWith('ljStateDimmed'));
72+
73+
describe('state error focus', () => {
74+
it('splits the possible states into valid and invalid ones', () => {
75+
expect(getStateErrorFocus(datagramSocket())).toEqual({
76+
calledMethod: 'receive',
77+
receiver: 'socket',
78+
possibleStates: ['bound', 'closed'],
79+
validStates: ['bound'],
80+
invalidStates: ['closed'],
81+
acceptedStates: ['unbound', 'bound', 'connected'],
82+
unknownState: false,
83+
});
84+
expect(getRelatedStates(getStateErrorFocus(datagramSocket())!)).toEqual(['bound', 'closed', 'unbound', 'connected']);
85+
});
86+
87+
it.each([
88+
['the definite found state', { possibleStates: undefined, definiteFoundState: 'closed' }, [], ['closed']],
89+
['the found states', { possibleStates: undefined, definiteFoundState: null }, ['bound'], ['closed']],
90+
])('falls back to %s without possible states', (_, errorContext, validStates, invalidStates) => {
91+
const focus = getStateErrorFocus(datagramSocket(errorContext));
92+
93+
expect(focus?.validStates).toEqual(validStates);
94+
expect(focus?.invalidStates).toEqual(invalidStates);
95+
});
96+
97+
it('marks valid and invalid states and colours every edge of the called method', () => {
98+
for (const focusOnError of [true, false]) {
99+
const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, focusOnError));
100+
101+
expect(diagram).toEqual(expect.arrayContaining([
102+
'state "✓ bound" as bound',
103+
'state "✕ closed" as closed',
104+
'class bound ljStateValid',
105+
'class closed ljStateInvalid',
106+
]));
107+
const send = focusOnError ? dimmedMethod('send') : 'send';
108+
for (const state of ['unbound', 'bound', 'connected']) {
109+
expect(diagram).toContain(`${state} --> ${state} : ${calledMethod('receive')}<br/>${send}`);
110+
}
111+
// accepted states that are not possible get only a class, without a mark in their name
112+
expect(diagram.filter(line => /^(state|class) .*\b(unbound|connected)\b/.test(line)))
113+
.toEqual(['class unbound,connected ljStateAccepted']);
114+
expect(diagram.join('\n')).not.toContain('ljStateErrorActual');
115+
}
116+
});
117+
118+
it('dims the states and transitions unrelated to the error when focused, without changing the labels', () => {
119+
const focused = createMermaidDiagram(datagramSocket(), 'TB', false, true);
120+
const full = createMermaidDiagram(datagramSocket(), 'TB', false, false);
121+
122+
expect(lines(focused)).toContain('class broken ljStateDimmed');
123+
// both initial transitions, then bind, disconnect, close from unbound and connected, repair, and discard
124+
expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge11 edge12']);
125+
// focusing only adds styling hooks, so the layout stays the same
126+
expect(withoutDimming(focused)).toEqual(lines(full));
127+
expect(full).not.toMatch(/ljStateDimmed|lj-dimmed/);
128+
});
129+
130+
it('only keeps the failed method and the ways out of the possible states bright', () => {
131+
const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true));
132+
133+
expect(diagram).toEqual(expect.arrayContaining([
134+
// leaving a possible state for another state
135+
'bound --> connected : connect',
136+
'bound --> closed : close',
137+
// self loops and transitions from other states
138+
`bound --> bound : ${calledMethod('receive')}<br/>${dimmedMethod('send')}`,
139+
`unbound --> bound : ${dimmedMethod('bind')}`,
140+
`connected --> bound : ${dimmedMethod('disconnect')}`,
141+
`broken --> closed : ${dimmedMethod('discard')}`,
142+
]));
143+
});
144+
145+
it('dims each method of a merged label, and the transition only when all of them are dimmed', () => {
146+
const sm = datagramSocket();
147+
sm.transitions.push(
148+
{ from: 'broken', to: 'broken', label: 'receive' },
149+
{ from: 'bound', to: 'connected', label: 'reconnect' },
150+
);
151+
152+
const diagram = createMermaidDiagram(sm, 'TB', false, true);
153+
expect(lines(diagram)).toEqual(expect.arrayContaining([
154+
`broken --> broken : ${dimmedMethod('repair')}<br/>${calledMethod('receive')}`,
155+
'bound --> connected : connect<br/>reconnect',
156+
]));
157+
// the self loop of broken keeps its path for the failed method
158+
expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge12']);
159+
});
160+
161+
it('dims the conditions of a dimmed method with it', () => {
162+
const sm = datagramSocket();
163+
sm.transitions[0] = { ...sm.transitions[0], fromCondition: 'unbound(this)' };
164+
165+
expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(
166+
`unbound --> bound : ${dimmedMethod('<span class="state-cond state-cond-pre">unbound(this)</span><br/>bind')}`);
167+
});
168+
169+
it('only colours labels that match the called method exactly', () => {
170+
const sm = datagramSocket();
171+
sm.transitions.push({ from: 'bound', to: 'bound', label: 'receiveAll' });
172+
173+
expect(lines(createMermaidDiagram(sm, 'TB', false, true)))
174+
.toContain(`bound --> bound : ${calledMethod('receive')}<br/>${dimmedMethod('send')}<br/>${dimmedMethod('receiveAll')}`);
175+
});
176+
177+
it.each([
178+
['no accepted states', { expectedStates: [], possibleStates: [] }],
179+
['accepted states outside the machine', { expectedStates: ['listening'] }],
180+
])('does not focus with %s', (_, errorContext) => {
181+
const sm = datagramSocket(errorContext);
182+
183+
expect(getStateErrorFocus(sm)).toBeNull();
184+
const diagram = createMermaidDiagram(sm, 'TB', false, true);
185+
expect(diagram).not.toMatch(/ljStateDimmed|lj-dimmed-transitions|ljStateValid|ljStateInvalid/);
186+
expect(diagram).toContain('class bound,closed ljStateErrorActual');
187+
});
188+
189+
it('ignores possible states outside the machine and falls back when none are given', () => {
190+
expect(getStateErrorFocus(datagramSocket({ possibleStates: ['closed', 'listening'] }))?.possibleStates).toEqual(['closed']);
191+
expect(getStateErrorFocus(datagramSocket({ possibleStates: [] }))?.possibleStates).toEqual(['bound', 'closed']);
192+
});
193+
194+
it('only hints at the accepted states when the state of the receiver is unknown', () => {
195+
const sm = datagramSocket({ possibleStates: ['unbound', 'bound', 'connected', 'closed', 'broken'], foundCondition: 'true' });
196+
197+
const focus = getStateErrorFocus(sm);
198+
expect(focus).toMatchObject({ unknownState: true, validStates: [], invalidStates: [] });
199+
const diagram = createMermaidDiagram(sm, 'TB', false, true);
200+
expect(lines(diagram)).toContain('class unbound,bound,connected ljStateAccepted');
201+
expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /);
202+
// every state is possible, so only the self loops other than the failed method are dimmed
203+
expect(lines(diagram)).toEqual(expect.arrayContaining(['unbound --> bound : bind', `broken --> broken : ${dimmedMethod('repair')}`]));
204+
});
205+
206+
it('marks the tree node example without an allowed state', () => {
207+
const diagram = lines(createMermaidDiagram(treeNode(), 'TB', false, true));
208+
209+
expect(diagram).toEqual(expect.arrayContaining([
210+
'state "✕ childrenAllowed" as childrenAllowed',
211+
'class childrenAllowed ljStateInvalid',
212+
'class maybeHasChildren ljStateAccepted',
213+
`maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`,
214+
// add leaves childrenAllowed, removeAllChildren does not start from a possible state
215+
'childrenAllowed --> maybeHasChildren : add',
216+
`maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`,
217+
]));
218+
expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/);
219+
// the constructor is dimmed although it leads to a possible state
220+
expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge2']);
221+
});
222+
223+
it('keeps the previous highlighting for an error context without the accepted states', () => {
224+
const sm = treeNode();
225+
sm.errorContext = { calledMethod: 'getFirstChild', actualStates: ['childrenAllowed'] };
226+
227+
expect(getStateErrorFocus(sm)).toBeNull();
228+
const diagram = createMermaidDiagram(sm, 'TB', false, true);
229+
expect(diagram).toBe(createMermaidDiagram(sm, 'TB', false, false));
230+
expect(diagram).toContain(`maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`);
231+
expect(diagram).toContain('class childrenAllowed ljStateErrorActual');
232+
expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateAccepted|ljStateDimmed|" as /);
233+
});
234+
235+
it('leaves the general diagram unchanged without an error', () => {
236+
expect(createMermaidDiagram(treeNode(null), 'TB', false, true)).toBe([
237+
'stateDiagram-v2',
238+
' direction TB',
239+
' [*] --> childrenAllowed : DefaultMutableTreeNode(Object)',
240+
' childrenAllowed --> maybeHasChildren : add',
241+
' maybeHasChildren --> childrenAllowed : removeAllChildren',
242+
' maybeHasChildren --> maybeHasChildren : getFirstChild',
243+
].join('\n'));
244+
});
245+
});
246+
247+
describe('rendered state error focus', () => {
248+
afterEach(() => {
249+
document.body.innerHTML = '';
250+
});
251+
252+
it('dims the rendered path and label of the listed transitions', async () => {
253+
const diagram = createMermaidDiagram(datagramSocket(), 'TB', false, true);
254+
document.body.innerHTML = '<div class="diagram-container"><div id="diagram-wrapper"><pre class="mermaid"></pre></div></div>';
255+
const pre = document.querySelector('.mermaid')!;
256+
pre.textContent = diagram;
257+
// edge markup copied from a Mermaid 11 state diagram rendered with the ELK layout
258+
const run = vi.fn(async () => {
259+
pre.innerHTML = '<svg>' + ['edge0', 'edge1', 'edge12'].map(id =>
260+
`<path class="transition" data-id="${id}"></path><g class="edgeLabel"><g class="label" data-id="${id}"></g></g>`).join('') + '</svg>';
261+
});
262+
263+
await renderMermaidDiagram(document, { mermaid: { run } });
264+
265+
const dimmed = (selector: string) => Array.from(document.querySelectorAll(selector), node => node.getAttribute('data-id'));
266+
expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']);
267+
expect(dimmed('g.label.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']);
268+
});
269+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { LJStateMachine } from "../types/fsm";
2+
3+
export const VALID_STATE_ICON = '✓';
4+
export const INVALID_STATE_ICON = '✕';
5+
6+
/**
7+
* The selected state error of a single state machine, split by the possible states of the receiver
8+
*/
9+
export type StateErrorFocus = {
10+
calledMethod: string | null;
11+
receiver: string | null;
12+
// states the receiver may be in at the call
13+
possibleStates: string[];
14+
// possible states from which the call is accepted
15+
validStates: string[];
16+
// possible states from which the call is rejected
17+
invalidStates: string[];
18+
// states from which the call is accepted
19+
acceptedStates: string[];
20+
// whether nothing is known about the state of the receiver, so that every state is possible
21+
unknownState: boolean;
22+
};
23+
24+
/**
25+
* Gets the state error to focus on from the error context of a state machine
26+
* @param sm
27+
* @returns the state error focus, or null when there is no error or the server reports no accepted states
28+
*/
29+
export function getStateErrorFocus(sm: LJStateMachine | undefined): StateErrorFocus | null {
30+
const context = sm?.errorContext;
31+
const acceptedStates = sm?.states.filter(state => context?.expectedStates?.includes(state)) ?? [];
32+
if (!sm || !context || acceptedStates.length === 0) return null;
33+
34+
const possible = context.possibleStates?.length
35+
? context.possibleStates
36+
: context.definiteFoundState ? [context.definiteFoundState] : context.actualStates;
37+
const possibleStates = sm.states.filter(state => possible.includes(state));
38+
// marking every state as valid or invalid would only say that the state is unknown
39+
const unknownState = possibleStates.length === sm.states.length;
40+
const markedStates = unknownState ? [] : possibleStates;
41+
return {
42+
calledMethod: context.calledMethod,
43+
receiver: context.receiver ?? null,
44+
possibleStates,
45+
validStates: markedStates.filter(state => acceptedStates.includes(state)),
46+
invalidStates: markedStates.filter(state => !acceptedStates.includes(state)),
47+
acceptedStates,
48+
unknownState,
49+
};
50+
}
51+
52+
/**
53+
* Gets the states related to a state error: the possible states of the receiver and the accepted states
54+
* @param focus
55+
* @returns the related states
56+
*/
57+
export function getRelatedStates(focus: StateErrorFocus): string[] {
58+
return [...new Set([...focus.possibleStates, ...focus.acceptedStates])];
59+
}
60+
61+
/**
62+
* Gets the text shown for the rejected call, e.g. root.getFirstChild()
63+
* @param focus
64+
* @returns the call text, or null when the called method is unknown
65+
*/
66+
export function getRejectedCallText(focus: StateErrorFocus): string | null {
67+
if (!focus.calledMethod) return null;
68+
return `${focus.receiver ? `${focus.receiver}.` : ''}${focus.calledMethod}()`;
69+
}

0 commit comments

Comments
 (0)