Skip to content

Commit bd88a56

Browse files
committed
feat: client sends variable ranges to the server and drops its own range creation
1 parent 824f71c commit bd88a56

5 files changed

Lines changed: 363 additions & 592 deletions

File tree

‎src/ansys/visor/visor-client/src/VisorFrontend.tsx‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export class VisorFrontend {
6565
// Respect base path prefix set by the Dash component (for reverse-proxy deployments).
6666
const bp: string = ((window as any).__visorArgs?.basePath ?? '').replace(/\/$/, '');
6767

68-
const self = this;
6968
this.globalVariableCollection = variableManager.globalVariableCollection;
7069
this.#unit = '';
7170
this.darkMode = darkMode;
@@ -99,6 +98,18 @@ export class VisorFrontend {
9998
remover?.();
10099
};
101100
this.defaultActorColor = [];
101+
// Reports one variable slot's range; errors are logged, not rethrown,
102+
// since the client already applied the range locally.
103+
this.sendVariableRangeAsync = async (variableId, component, min, max) => {
104+
try {
105+
await triggerSender('set_variable_range', { variableId, component, min, max });
106+
} catch (err) {
107+
console.error(
108+
`[VISOR] variable range trigger send failed: variableId='${variableId}' component=${component}`,
109+
err
110+
);
111+
}
112+
};
102113
this.setVariableRangeAsync = async (variableId, component, min, max) => {
103114
const variable = variableManager.globalVariableCollection.getVariable(variableId);
104115
if (variable == null) {
@@ -113,6 +124,7 @@ export class VisorFrontend {
113124
await actorNode.setScalarRangeAsync(min, max);
114125
}
115126
}
127+
await this.sendVariableRangeAsync(variableId, component, min, max);
116128
};
117129
this.addCameraChangedListener = (callback) => {
118130
return renderer.addCameraChangedListener(callback);
@@ -508,24 +520,7 @@ export class VisorFrontend {
508520
}
509521
}
510522
}
511-
// wait for all the parts to be updated before updating the variable ranges
512-
await Promise.all(promises);
513-
promises.length = 0;
514-
for (const variable_state of sceneState.getVariableStates()) {
515-
const idStr = variable_state.id;
516-
if (variable_state.magnitudeRange !== undefined) {
517-
const range = variable_state.magnitudeRange;
518-
const promise = self.setVariableRangeAsync(idStr, -1, range[0], range[1]);
519-
promises.push(promise);
520-
}
521-
for (let i = 0; i < variable_state.ranges.length; i++) {
522-
const range = variable_state.ranges[i];
523-
if (range !== undefined) {
524-
const promise = self.setVariableRangeAsync(idStr, i, range[0], range[1]);
525-
promises.push(promise);
526-
}
527-
}
528-
}
523+
// Every part is applied before the tree and the panel re-read them.
529524
await Promise.all(promises);
530525
if (updateUI) {
531526
await panelTopRightUtilPromise;
@@ -580,6 +575,14 @@ export class VisorFrontend {
580575
toggleFullScreenAsync: () => Promise<void>;
581576
addCameraChangedListener: (callback: (cameraState: VisorCameraState) => void) => () => void;
582577
globalVariableCollection: VisorVariableCollection;
578+
/** Reports one variable slot's range to the server, -1 being the magnitude; a failed send is logged, not rethrown. */
579+
sendVariableRangeAsync: (
580+
variableId: string,
581+
component: number,
582+
min: number,
583+
max: number
584+
) => Promise<void>;
585+
/** Writes a variable slot's range to the held record, applies it to every part coloured by that slot, then reports it to the server. */
583586
setVariableRangeAsync: (
584587
variableId: string,
585588
component: number,

‎src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx‎

Lines changed: 205 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,14 @@ import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.
2929
* delivery, after the tree has synchronized, so a delivered range reaches
3030
* the legend with no selection change.
3131
*
32-
* Expected values are hand-written literals. The trigger sender is a double
33-
* that absorbs every send; nothing here asserts on it.
32+
* 4. The range trigger is sent once per edit, carrying the server's
33+
* component convention, and never for a delivery. A delivered range
34+
* that differs from the one a part last applied reaches that part with
35+
* no send at all.
36+
*
37+
* Expected values are hand-written literals. The trigger-sender double
38+
* absorbs every send except in the range-trigger tests, which assert its
39+
* calls after clearing it and the renderer double at the end of their setup.
3440
*
3541
* jsdom has no `ResizeObserver` and jest here runs with no `setupFiles`, so
3642
* the stub below is this module's own.
@@ -47,6 +53,7 @@ const PART_A_ID = 1;
4753
const PART_B_ID = 2;
4854
const PRESSURE_ID = 'POINT::pressure::1';
4955
const TEMPERATURE_ID = 'POINT::temperature::1';
56+
const VELOCITY_ID = 'POINT::velocity::2';
5057

5158
/** A complete wire record for `pressure`, custom range [2, 8] inside default [0, 10]. */
5259
function pressureRecord(overrides: Record<string, unknown> = {}): Record<string, unknown> {
@@ -79,6 +86,27 @@ function temperatureRecord(): Record<string, unknown> {
7986
};
8087
}
8188

89+
/** A complete wire record for two-component `velocity`, listing part A. */
90+
function velocityRecord(): Record<string, unknown> {
91+
return {
92+
id: VELOCITY_ID,
93+
arrayName: 'velocity',
94+
type: 'POINT',
95+
numComponents: 2,
96+
partIds: [PART_A_ID],
97+
defaultMagnitudeRange: [0, 10],
98+
defaultRanges: [
99+
[-1, 1],
100+
[-2, 2],
101+
],
102+
magnitudeRange: [0, 10],
103+
ranges: [
104+
[-1, 1],
105+
[-2, 2],
106+
],
107+
};
108+
}
109+
82110
function makeSceneGraphNode() {
83111
return {
84112
id: 0,
@@ -165,14 +193,21 @@ function makeRendererDouble() {
165193

166194
type RendererDouble = ReturnType<typeof makeRendererDouble>;
167195

168-
function makeFrontend(): { frontend: VisorFrontend; renderer: RendererDouble } {
196+
type TriggerSenderDouble = jest.Mock<Promise<unknown>, [string, unknown]>;
197+
198+
function makeFrontend(): {
199+
frontend: VisorFrontend;
200+
renderer: RendererDouble;
201+
triggerSender: TriggerSenderDouble;
202+
} {
169203
const renderer = makeRendererDouble();
204+
const triggerSender = jest.fn(async () => undefined) as unknown as TriggerSenderDouble;
170205
const frontend = new VisorFrontend(
171206
renderer as unknown as IRenderer,
172207
makeSceneGraphNode() as unknown as VisorVtkSceneNode,
173-
jest.fn(async () => undefined)
208+
triggerSender
174209
);
175-
return { frontend, renderer };
210+
return { frontend, renderer, triggerSender };
176211
}
177212

178213
/** The tree-view util members the frontend and the mounted panel reach. */
@@ -430,49 +465,90 @@ describe('setAppStateAsync holds a delivered variable block', () => {
430465
});
431466
});
432467

433-
describe('the legend follows a delivery with no selection change', () => {
434-
/**
435-
* A real frontend holding `pressure` at [2, 8], with part A coloured by
436-
* its magnitude and selected in the tree, and the top-right panel mounted
437-
* on it.
438-
*/
439-
async function mountOnDeliveredRecord(): Promise<{
440-
frontend: VisorFrontend;
441-
container: HTMLElement;
442-
}> {
443-
const { frontend } = makeFrontend();
444-
const part = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID];
445-
frontend.setTreeViewUtil(makeTreeViewUtilDouble([part]));
446-
await frontend.setAppStateAsync(
447-
{ scene: { variableStates: { [PRESSURE_ID]: pressureRecord() } } },
448-
false
449-
);
450-
await part.setColorVariableAsync(PRESSURE_ID, -1);
468+
/**
469+
* A real frontend holding one delivered record, with part A coloured by the
470+
* given slot and selected in the tree, and the top-right panel mounted on it.
471+
*/
472+
async function mountOnDeliveredRecord(
473+
record: Record<string, unknown>,
474+
variableId: string,
475+
component: number
476+
): Promise<{
477+
frontend: VisorFrontend;
478+
container: HTMLElement;
479+
triggerSender: TriggerSenderDouble;
480+
}> {
481+
const { frontend, triggerSender } = makeFrontend();
482+
const part = frontend.sceneGraph.descendantActorNodesOrSelfDictionary[PART_A_ID];
483+
frontend.setTreeViewUtil(makeTreeViewUtilDouble([part]));
484+
await frontend.setAppStateAsync(
485+
{ scene: { variableStates: { [variableId]: record } } },
486+
false
487+
);
488+
await part.setColorVariableAsync(variableId, component);
451489

452-
let container: HTMLElement = null!;
453-
await act(async () => {
454-
container = render(
455-
<Panel_TopRight visorState={frontend} onLoad={() => {}} />
456-
).container;
457-
});
458-
await frontend.panelTopRightUtilPromise;
459-
return { frontend, container };
460-
}
461-
462-
/**
463-
* The legend's min or max row, located by its heading: the ids in this
464-
* component are `randomId()`-generated.
465-
*/
466-
function legendRow(container: HTMLElement, heading: 'Min' | 'Max') {
467-
const label = within(container).getByText(heading, { selector: 'div' }).closest('label')!;
468-
return {
469-
input: label.querySelector('input') as HTMLInputElement,
470-
reset: label.querySelector('a') as HTMLAnchorElement,
471-
};
472-
}
490+
let container: HTMLElement = null!;
491+
await act(async () => {
492+
container = render(
493+
<Panel_TopRight visorState={frontend} onLoad={() => {}} />
494+
).container;
495+
});
496+
await frontend.panelTopRightUtilPromise;
497+
return { frontend, container, triggerSender };
498+
}
499+
500+
/**
501+
* The legend's min or max row, located by its heading: the ids in this
502+
* component are `randomId()`-generated.
503+
*/
504+
function legendRow(container: HTMLElement, heading: 'Min' | 'Max') {
505+
const label = within(container).getByText(heading, { selector: 'div' }).closest('label')!;
506+
return {
507+
input: label.querySelector('input') as HTMLInputElement,
508+
reset: label.querySelector('a') as HTMLAnchorElement,
509+
};
510+
}
511+
512+
/** Types a range into the legend and clicks Apply, then lets its handler settle. */
513+
async function applyRange(container: HTMLElement, min: string, max: string): Promise<void> {
514+
legendRow(container, 'Min').input.value = min;
515+
legendRow(container, 'Max').input.value = max;
516+
const apply = within(container).getByText('Apply', { selector: 'button' });
517+
await act(async () => {
518+
fireEvent.click(apply);
519+
await new Promise((resolve) => setTimeout(resolve, 0));
520+
});
521+
}
473522

523+
/** The `set_variable_range` sends among a trigger-sender double's calls. */
524+
function rangeSends(triggerSender: TriggerSenderDouble): unknown[][] {
525+
return triggerSender.mock.calls.filter(([name]) => name === 'set_variable_range');
526+
}
527+
528+
/** A delivery holding one `pressure` record and colouring part A by its magnitude. */
529+
function deliveryColouringPartA(record: Record<string, unknown>) {
530+
return {
531+
scene: {
532+
variableStates: { [PRESSURE_ID]: record },
533+
datasetStates: {
534+
'0': {
535+
id: '0',
536+
partStates: {
537+
'1': { id: '1', variableId: PRESSURE_ID, variableComponent: -1 },
538+
},
539+
},
540+
},
541+
},
542+
};
543+
}
544+
545+
describe('the legend follows a delivery with no selection change', () => {
474546
test('a delivered custom range is shown in the legend', async () => {
475-
const { frontend, container } = await mountOnDeliveredRecord();
547+
const { frontend, container } = await mountOnDeliveredRecord(
548+
pressureRecord(),
549+
PRESSURE_ID,
550+
-1
551+
);
476552
expect(legendRow(container, 'Min').input.value).toBe('2');
477553
expect(legendRow(container, 'Max').input.value).toBe('8');
478554

@@ -494,7 +570,11 @@ describe('the legend follows a delivery with no selection change', () => {
494570
});
495571

496572
test('reset shows the delivered default range', async () => {
497-
const { frontend, container } = await mountOnDeliveredRecord();
573+
const { frontend, container } = await mountOnDeliveredRecord(
574+
pressureRecord(),
575+
PRESSURE_ID,
576+
-1
577+
);
498578

499579
await act(async () => {
500580
await frontend.setAppStateAsync(
@@ -516,3 +596,82 @@ describe('the legend follows a delivery with no selection change', () => {
516596
expect(minRow.input.value).toBe('-1');
517597
});
518598
});
599+
600+
describe('the range trigger is sent for an edit and never for a delivery', () => {
601+
test('an apply click on the magnitude sends one set_variable_range with component -1', async () => {
602+
const { container, triggerSender } = await mountOnDeliveredRecord(
603+
pressureRecord(),
604+
PRESSURE_ID,
605+
-1
606+
);
607+
triggerSender.mockClear();
608+
609+
await applyRange(container, '3', '7');
610+
611+
expect(triggerSender.mock.calls).toEqual([
612+
[
613+
'set_variable_range',
614+
{ variableId: 'POINT::pressure::1', component: -1, min: 3, max: 7 },
615+
],
616+
]);
617+
});
618+
619+
test('an apply click on component 1 sends component 1', async () => {
620+
const { container, triggerSender } = await mountOnDeliveredRecord(
621+
velocityRecord(),
622+
VELOCITY_ID,
623+
1
624+
);
625+
triggerSender.mockClear();
626+
627+
await applyRange(container, '-1', '1');
628+
629+
expect(triggerSender.mock.calls).toEqual([
630+
[
631+
'set_variable_range',
632+
{ variableId: 'POINT::velocity::2', component: 1, min: -1, max: 1 },
633+
],
634+
]);
635+
});
636+
637+
test('a delivered push sends no set_variable_range', async () => {
638+
const { frontend, triggerSender } = makeFrontend();
639+
640+
await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false);
641+
642+
expect(rangeSends(triggerSender)).toEqual([]);
643+
});
644+
645+
test('a changed delivered range reaches a part already coloured by that slot, sending nothing', async () => {
646+
const { frontend, renderer, triggerSender } = makeFrontend();
647+
await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false);
648+
renderer.setScalarRangeAsync.mockClear();
649+
renderer.sendPartColorVariableAsync.mockClear();
650+
triggerSender.mockClear();
651+
652+
// Magnitude moves to [3, 7]; component 0 stays at [2, 8].
653+
await frontend.setAppStateAsync(
654+
deliveryColouringPartA(pressureRecord({ magnitudeRange: [3, 7] })),
655+
false
656+
);
657+
658+
expect(renderer.setScalarRangeAsync.mock.calls).toEqual([[1, 3, 7]]);
659+
expect(renderer.sendPartColorVariableAsync).not.toHaveBeenCalled();
660+
expect(rangeSends(triggerSender)).toEqual([]);
661+
});
662+
663+
test('a delivered range differing from the last edit reaches a part already coloured by that slot', async () => {
664+
const { frontend, renderer, triggerSender } = makeFrontend();
665+
await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false);
666+
await frontend.setVariableRangeAsync(PRESSURE_ID, -1, 3, 7);
667+
renderer.setScalarRangeAsync.mockClear();
668+
renderer.sendPartColorVariableAsync.mockClear();
669+
triggerSender.mockClear();
670+
671+
await frontend.setAppStateAsync(deliveryColouringPartA(pressureRecord()), false);
672+
673+
expect(renderer.setScalarRangeAsync.mock.calls).toEqual([[1, 2, 8]]);
674+
expect(renderer.sendPartColorVariableAsync).not.toHaveBeenCalled();
675+
expect(rangeSends(triggerSender)).toEqual([]);
676+
});
677+
});

0 commit comments

Comments
 (0)