Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 150 additions & 35 deletions client/src/webview/diagram-focus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ function lines(diagram: string): string[] {
const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:'));
const calledMethod = (label: string) => `<span class="lj-state-error-method">${label}</span>`;
const dimmedMethod = (label: string) => `<span class="lj-dimmed-method">${label}</span>`;
// removes the hooks that only style the focused diagram
const withoutDimming = (diagram: string) => lines(diagram.replace(/<span class="lj-dimmed-method">(.*?)<\/span>/g, '$1'))
.filter(line => !line.startsWith('%%') && !line.endsWith('ljStateDimmed'));
const collapsedMethods = (labels: string[], count: string) =>
`<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>`;

describe('state error focus', () => {
it('splits the possible states into valid and invalid ones', () => {
Expand All @@ -93,6 +92,7 @@ describe('state error focus', () => {
'class closed ljStateInvalid',
]));
const send = focusOnError ? dimmedMethod('send') : 'send';
// a single dimmed method keeps its name
for (const state of ['unbound', 'bound', 'connected']) {
expect(diagram).toContain(`${state} --> ${state} : ${calledMethod('receive')}<br/>${send}`);
}
Expand All @@ -103,16 +103,13 @@ describe('state error focus', () => {
}
});

it('dims the states and transitions unrelated to the error when focused, without changing the labels', () => {
it('dims the states unrelated to the error and the transitions without the failed method when focused', () => {
const focused = createMermaidDiagram(datagramSocket(), 'TB', false, true);
const full = createMermaidDiagram(datagramSocket(), 'TB', false, false);

expect(lines(focused)).toContain('class broken ljStateDimmed');
// both initial transitions, then bind, disconnect, close from unbound and connected, repair, and discard
expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge11 edge12']);
// focusing only adds styling hooks, so the layout stays the same
expect(withoutDimming(focused)).toEqual(lines(full));
expect(full).not.toMatch(/ljStateDimmed|lj-dimmed/);
// both constructors, then bind, connect, disconnect, every close, repair, and discard
expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12']);
expect(focused).not.toContain('lj-fix');
});

it('draws entry transitions from the initial pseudo state, dimmed when focused', () => {
Expand All @@ -126,35 +123,52 @@ describe('state error focus', () => {
expect(dimmedTransitions(createMermaidDiagram(sm, 'TB', false, true))[0]).toMatch(/^%% lj-dimmed-transitions: edge0 edge1\b/);
});

it('only keeps the failed method and the ways out of the possible states bright', () => {
const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true));
it('merges the entry transitions of each state into one collapsed transition when focused', () => {
const sm = { ...treeNode(), entryTransitions: [
{ to: 'maybeHasChildren', label: 'isLeaf', toCondition: '!return' },
{ to: 'maybeHasChildren', label: 'getChildCount', toCondition: 'return > 0' },
] };

expect(diagram).toEqual(expect.arrayContaining([
// leaving a possible state for another state
expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(`[*] --> maybeHasChildren : ${collapsedMethods([
'isLeaf<br/><span class="state-cond state-cond-post">!return</span>',
'getChildCount<br/><span class="state-cond state-cond-post">return &gt; 0</span>',
], '2 methods')}`);
});

it('leaves the full diagram unchanged when showing all', () => {
const full = createMermaidDiagram(datagramSocket(), 'TB', false, false);

expect(full).not.toMatch(/ljStateDimmed|lj-dimmed|lj-fix|lj-collapsed/);
expect(lines(full)).toEqual(expect.arrayContaining([
'[*] --> bound : new DatagramSocket()',
'[*] --> broken',
'bound --> connected : connect',
'bound --> closed : close',
// self loops and transitions from other states
`bound --> bound : ${calledMethod('receive')}<br/>${dimmedMethod('send')}`,
`unbound --> bound : ${dimmedMethod('bind')}`,
`connected --> bound : ${dimmedMethod('disconnect')}`,
`broken --> closed : ${dimmedMethod('discard')}`,
]));
});

it('dims each method of a merged label, and the transition only when all of them are dimmed', () => {
it('collapses the dimmed methods of a label into a count, keeping them for the list on hover', () => {
const sm = datagramSocket();
sm.transitions.push(
{ from: 'broken', to: 'broken', label: 'receive' },
{ from: 'bound', to: 'connected', label: 'reconnect' },
{ from: 'bound', to: 'bound', label: 'getPort' },
{ from: 'bound', to: 'bound', label: 'getOption' },
{ from: 'unbound', to: 'bound', label: 'bindAll' },
// an overload is counted once
{ from: 'unbound', to: 'bound', label: 'bind' },
);

const diagram = createMermaidDiagram(sm, 'TB', false, true);
expect(lines(diagram)).toEqual(expect.arrayContaining([
`broken --> broken : ${dimmedMethod('repair')}<br/>${calledMethod('receive')}`,
'bound --> connected : connect<br/>reconnect',
expect(lines(createMermaidDiagram(sm, 'TB', false, true))).toEqual(expect.arrayContaining([
`bound --> bound : ${calledMethod('receive')}<br/>${collapsedMethods(['send', 'getPort', 'getOption'], '+3 more')}`,
`unbound --> bound : ${collapsedMethods(['bind', 'bindAll'], '2 methods')}`,
]));
// the self loop of broken keeps its path for the failed method
expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge12']);
});

it('merges the constructors of each state into one collapsed transition when focused', () => {
const sm = treeNode();
sm.initialTransitions.push({ to: 'childrenAllowed', constructorSignature: 'DefaultMutableTreeNode()' });

const diagram = lines(createMermaidDiagram(sm, 'TB', false, true));
expect(diagram).toContain(`[*] --> childrenAllowed : ${collapsedMethods(['DefaultMutableTreeNode(Object)', 'DefaultMutableTreeNode()'], '2 constructors')}`);
expect(diagram.filter(line => line.startsWith('[*]'))).toHaveLength(1);
});

it('dims the conditions of a dimmed method with it', () => {
Expand All @@ -170,7 +184,7 @@ describe('state error focus', () => {
sm.transitions.push({ from: 'bound', to: 'bound', label: 'receiveAll' });

expect(lines(createMermaidDiagram(sm, 'TB', false, true)))
.toContain(`bound --> bound : ${calledMethod('receive')}<br/>${dimmedMethod('send')}<br/>${dimmedMethod('receiveAll')}`);
.toContain(`bound --> bound : ${calledMethod('receive')}<br/>${collapsedMethods(['send', 'receiveAll'], '+2 more')}`);
});

it('does not focus without accepted states', () => {
Expand All @@ -194,8 +208,8 @@ describe('state error focus', () => {
const diagram = createMermaidDiagram(sm, 'TB', false, true);
expect(lines(diagram)).toContain('class unbound,bound,connected ljStateAccepted');
expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /);
// every state is possible, so only the self loops other than the failed method are dimmed
expect(lines(diagram)).toEqual(expect.arrayContaining(['unbound --> bound : bind', `broken --> broken : ${dimmedMethod('repair')}`]));
// only the failed method stays bright
expect(lines(diagram)).toEqual(expect.arrayContaining([`unbound --> bound : ${dimmedMethod('bind')}`, `broken --> broken : ${dimmedMethod('repair')}`]));
});

it('marks the tree node example without an allowed state', () => {
Expand All @@ -206,13 +220,12 @@ describe('state error focus', () => {
'class childrenAllowed ljStateInvalid',
'class maybeHasChildren ljStateAccepted',
`maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`,
// add leaves childrenAllowed, removeAllChildren does not start from a possible state
'childrenAllowed --> maybeHasChildren : add',
`childrenAllowed --> maybeHasChildren : ${dimmedMethod('add')}`,
`maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`,
]));
expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/);
// the constructor is dimmed although it leads to a possible state
expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge2']);
expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2']);
});

it('only highlights the possible states when the accepted states are unknown', () => {
Expand Down Expand Up @@ -262,6 +275,108 @@ describe('rendered state error focus', () => {
});
});

describe('collapsed labels', () => {
afterEach(() => {
document.body.innerHTML = '';
});

async function render() {
const sm = datagramSocket();
sm.transitions.push({ from: 'bound', to: 'bound', label: 'getPort' });
const label = lines(createMermaidDiagram(sm, 'TB', false, true)).find(line => line.startsWith('bound --> bound'))!.split(' : ')[1];
document.body.innerHTML = '<div class="diagram-container"><div id="diagram-wrapper"><pre class="mermaid">x</pre></div></div>';
const pre = document.querySelector('.mermaid')!;
const run = vi.fn(async () => {
pre.innerHTML = `<svg><g class="edgeLabel"><foreignObject><div>${label}</div></foreignObject></g></svg>`;
});
await renderMermaidDiagram(document, { mermaid: { run } });
return document.querySelector('.lj-collapsed-methods')!;
}
const popover = () => document.querySelector('.lj-methods-popover');
const items = () => Array.from(document.querySelectorAll('.lj-methods-popover .lj-collapsed-item'), node => node.textContent);

it('lists the methods on hover without rendering the diagram again', async () => {
const label = await render();
expect(popover()).toBeNull();

label.dispatchEvent(new MouseEvent('mouseenter'));
expect(items()).toEqual(['send', 'getPort']);
label.dispatchEvent(new MouseEvent('mouseleave'));
expect(popover()).toBeNull();
});

it('places the list beside the label, off the states', async () => {
const label = await render();
const box = (left: number, top: number, width: number, height: number) => ({ left, top, right: left + width, bottom: top + height }) as DOMRect;
label.getBoundingClientRect = () => box(100, 100, 60, 20);
// a state right of the label
document.querySelector('svg')!.insertAdjacentHTML('beforeend', '<g class="node"></g>');
document.querySelector<Element>('g.node')!.getBoundingClientRect = () => box(170, 50, 200, 120);
const size = (name: 'offsetWidth' | 'offsetHeight', value: number) => vi.spyOn(HTMLElement.prototype, name, 'get').mockReturnValue(value);
const spies = [size('offsetWidth', 80), size('offsetHeight', 40)];
// a 1000 x 800 page, where fixed elements land where they are put
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000);
vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800);
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLDivElement) {
return box(parseFloat(this.style.left) || 0, parseFloat(this.style.top) || 0, 80, 40);
});

label.dispatchEvent(new MouseEvent('mouseenter'));
expect((popover() as HTMLElement).style.left).toBe(`${100 - 6 - 80}px`);
expect((popover() as HTMLElement).style.top).toBe('90px');
spies.forEach(spy => spy.mockRestore());
vi.restoreAllMocks();
});

it('keeps the list after a click until the next click, a pan, or a zoom', async () => {
const label = await render();

label.dispatchEvent(new MouseEvent('mouseenter'));
label.dispatchEvent(new MouseEvent('click', { bubbles: true }));
label.dispatchEvent(new MouseEvent('mouseleave'));
expect(items()).toEqual(['send', 'getPort']);
label.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(popover()).toBeNull();

label.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.querySelector('svg')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
expect(popover()).toBeNull();

label.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.querySelector('.diagram-container')!.dispatchEvent(new WheelEvent('wheel'));
expect(popover()).toBeNull();
});
});

describe('fitting a focused diagram', () => {
afterEach(() => {
document.body.innerHTML = '';
});

async function render(focused: boolean, height: number) {
document.body.innerHTML = `<div class="diagram-container"><div class="diagram-viewport${focused ? ' diagram-focused' : ''}"><div id="diagram-wrapper"><pre class="mermaid">x</pre></div></div></div>`;
const pre = document.querySelector('.mermaid')!;
const run = vi.fn(async () => {
pre.innerHTML = '<svg><g class="ljStateInvalid"></g></svg>';
Object.defineProperty(pre.querySelector('svg')!, 'clientHeight', { value: height });
});
document.querySelector('.diagram-viewport')!.getBoundingClientRect = () => ({ top: 200 }) as DOMRect;
await renderMermaidDiagram(document, { mermaid: { run }, innerHeight: 816 });
return (document.querySelector('.mermaid svg') as SVGSVGElement).style.maxHeight;
}

it('shrinks a tall diagram to the panel, but not below a readable scale', async () => {
// 816 - 200 - 16 = 600 pixels are left below the summary
expect(await render(true, 900)).toBe('600px');
expect(await render(true, 2000)).toBe('1200px');
expect(await render(true, 500)).toBe('');
});

it('leaves the diagram as it is when showing all', async () => {
expect(await render(false, 900)).toBe('');
});
});

describe('state errors on several state sets', () => {
const tiling: LJStateMachine = { ...treeNode(null), className: 'ImageWriteParam', stateSet: 0 };
const compression: LJStateMachine = { ...treeNode(), className: 'ImageWriteParam', stateSet: 1 };
Expand Down
Loading
Loading