FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
InspectorControls.test.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import { describe, expect, test } from 'bun:test';
5import {
6 formatNumberFieldValue,
7 resolveNumberFieldBlur,
8 resolveTextFieldBlur,
9} from './InspectorControls';
10import {
11 createDechirpReference,
12 createFmcwModeConfig,
13 DECHIRP_MODE_OPTIONS,
14 DECHIRP_REFERENCE_SOURCE_OPTIONS,
15 getAvailableDechirpReferenceSourceOptions,
16 getCompatibleWaveforms,
17 getFmcwEmitterNames,
18 getFmcwWaveformNames,
19 getPulsedRadarFieldLabels,
20 RADAR_MODE_OPTIONS,
21 resolveWaveformSelectValue,
22 shouldClearWaveformForRadarType,
23} from './PlatformComponentInspector';
24import {
25 ensureCubicPositionWaypoints,
26 ensureCubicRotationWaypoints,
27} from './PlatformInspector';
28import {
29 createWaveformForType,
30 getVisibleWaveformFieldLabels,
31 WAVEFORM_TYPE_OPTIONS,
32} from './WaveformInspector';
33
34describe('InspectorControls blur resolution', () => {
35 test('reverts required numeric fields when blurred empty', () => {
36 expect(resolveNumberFieldBlur('', 42, 'revert')).toEqual({
37 action: 'revert',
38 error: 'Value required.',
39 nextDraft: '42',
40 });
41 });
42
43 test('commits null for unsettable numeric fields when blurred empty', () => {
44 expect(resolveNumberFieldBlur('', 42, 'null')).toEqual({
45 action: 'commit',
46 nextDraft: '',
47 value: null,
48 });
49 });
50
51 test('reverts timing noise-entry drafts instead of producing empty commits', () => {
52 expect(resolveNumberFieldBlur('', 0.5, 'revert')).toEqual({
53 action: 'revert',
54 error: 'Value required.',
55 nextDraft: '0.5',
56 });
57 });
58
59 test('rejects invalid numeric drafts on blur', () => {
60 expect(resolveNumberFieldBlur('-', 7, 'revert')).toEqual({
61 action: 'revert',
62 error: 'Invalid number.',
63 nextDraft: '7',
64 });
65 });
66
67 test('parses valid numeric drafts on blur', () => {
68 expect(resolveNumberFieldBlur('1e3', 7, 'revert')).toEqual({
69 action: 'commit',
70 nextDraft: '1000',
71 value: 1000,
72 });
73 });
74
75 test('reverts required text fields when blurred empty', () => {
76 expect(resolveTextFieldBlur(' ', 'Timing A', false)).toEqual({
77 action: 'revert',
78 error: 'Value required.',
79 nextDraft: 'Timing A',
80 });
81 });
82
83 test('commits non-empty text drafts', () => {
84 expect(
85 resolveTextFieldBlur('Updated Timing', 'Timing A', false)
86 ).toEqual({
87 action: 'commit',
88 nextDraft: 'Updated Timing',
89 value: 'Updated Timing',
90 });
91 });
92
93 test('formats null numeric values as an empty draft', () => {
94 expect(formatNumberFieldValue(null)).toBe('');
95 });
96});
97
98describe('Waveform inspector authoring options', () => {
99 test('offers file waveform types first and generated alternatives after them', () => {
100 expect(WAVEFORM_TYPE_OPTIONS).toEqual([
101 { value: 'pulsed_from_file', label: 'Pulse File' },
102 { value: 'cw_from_file', label: 'CW File' },
103 { value: 'fmcw_from_file', label: 'FMCW File' },
104 { value: 'cw', label: 'CW Tone (Generated)' },
105 {
106 value: 'fmcw_linear_chirp',
107 label: 'FMCW Linear Chirp (Generated)',
108 },
109 { value: 'fmcw_triangle', label: 'FMCW Triangle (Generated)' },
110 { value: 'stepped_frequency', label: 'SFCW' },
111 ]);
112 });
113
114 test('creates file waveform defaults and shows only the file field', () => {
115 for (const waveformType of [
116 'cw_from_file',
117 'fmcw_from_file',
118 ] as const) {
119 const waveform = createWaveformForType(
120 {
121 id: 'file',
122 type: 'Waveform',
123 name: 'File waveform',
124 waveformType: 'cw',
125 power: 10,
126 carrier_frequency: 1e9,
127 },
128 waveformType
129 );
130 expect(waveform).toMatchObject({ waveformType, filename: '' });
131 expect(getVisibleWaveformFieldLabels(waveformType)).toEqual([
132 'Waveform File (.h5)',
133 ]);
134 }
135 });
136
137 test('creates FMCW defaults while preserving common waveform fields', () => {
138 const fmcwWaveform = createWaveformForType(
139 {
140 id: '1',
141 type: 'Waveform',
142 name: 'Search chirp',
143 waveformType: 'cw',
144 power: 250,
145 carrier_frequency: 9.6e9,
146 chirp_count: 4.8,
147 start_frequency_offset: 125,
148 },
149 'fmcw_linear_chirp'
150 );
151
152 expect(fmcwWaveform).toMatchObject({
153 id: '1',
154 type: 'Waveform',
155 name: 'Search chirp',
156 waveformType: 'fmcw_linear_chirp',
157 direction: 'up',
158 power: 250,
159 carrier_frequency: 9.6e9,
160 chirp_bandwidth: 4e3,
161 chirp_duration: 1e-3,
162 chirp_period: 1e-3,
163 start_frequency_offset: 125,
164 chirp_count: 4,
165 });
166 });
167
168 test('creates SFCW defaults while preserving common waveform fields', () => {
169 const sfcwWaveform = createWaveformForType(
170 {
171 id: '2',
172 type: 'Waveform',
173 name: 'Step sweep',
174 waveformType: 'cw',
175 power: 125,
176 carrier_frequency: 5.8e9,
177 },
178 'stepped_frequency'
179 );
180
181 expect(sfcwWaveform).toMatchObject({
182 id: '2',
183 type: 'Waveform',
184 name: 'Step sweep',
185 waveformType: 'stepped_frequency',
186 power: 125,
187 carrier_frequency: 5.8e9,
188 start_frequency_offset: 0,
189 step_size: 1e3,
190 step_count: 16,
191 dwell_time: 1e-3,
192 step_period: 1e-3,
193 sweep_count: null,
194 });
195 });
196
197 test('reports FMCW chirp fields only for FMCW waveforms', () => {
198 expect(getVisibleWaveformFieldLabels('fmcw_linear_chirp')).toEqual([
199 'Direction',
200 'Chirp Bandwidth (Hz)',
201 'Chirp Duration (s)',
202 'Chirp Period (s)',
203 'Start Frequency Offset (Hz)',
204 'Chirp Count',
205 ]);
206 expect(getVisibleWaveformFieldLabels('fmcw_triangle')).toEqual([
207 'Chirp Bandwidth (Hz)',
208 'Chirp Duration (s)',
209 'Start Frequency Offset (Hz)',
210 'Triangle Count',
211 ]);
212 expect(getVisibleWaveformFieldLabels('stepped_frequency')).toEqual([
213 'Start Frequency Offset (Hz)',
214 'Step Size (Hz)',
215 'Step Count',
216 'Dwell Time (s)',
217 'Step Period (s)',
218 'Sweep Count',
219 ]);
220 expect(getVisibleWaveformFieldLabels('cw')).toEqual([]);
221 });
222});
223
224describe('Platform inspector authoring helpers', () => {
225 test('adds a second position waypoint before selecting cubic interpolation', () => {
226 const waypoints = [
227 {
228 id: 'position-1',
229 x: 1,
230 y: 2,
231 altitude: 3,
232 time: 4,
233 },
234 ];
235
236 const next = ensureCubicPositionWaypoints(waypoints);
237
238 expect(next).toHaveLength(2);
239 expect(next[0]).toEqual(waypoints[0]);
240 expect(next[1]).toMatchObject({
241 x: 1,
242 y: 2,
243 altitude: 3,
244 time: 5,
245 });
246 expect(next[1].id).not.toBe('position-1');
247 });
248
249 test('adds a second rotation waypoint before selecting cubic interpolation', () => {
250 const waypoints = [
251 {
252 id: 'rotation-1',
253 azimuth: 10,
254 elevation: 20,
255 time: 4,
256 },
257 ];
258
259 const next = ensureCubicRotationWaypoints(waypoints);
260
261 expect(next).toHaveLength(2);
262 expect(next[0]).toEqual(waypoints[0]);
263 expect(next[1]).toMatchObject({
264 azimuth: 10,
265 elevation: 20,
266 time: 5,
267 });
268 expect(next[1].id).not.toBe('rotation-1');
269 });
270});
271
272describe('Platform component inspector waveform compatibility', () => {
273 const waveforms = [
274 { id: '1', name: 'Pulse', waveformType: 'pulsed_from_file' },
275 { id: '2', name: 'Tone', waveformType: 'cw' },
276 { id: '3', name: 'Chirp', waveformType: 'fmcw_linear_chirp' },
277 { id: '4', name: 'Triangle', waveformType: 'fmcw_triangle' },
278 { id: '5', name: 'Steps', waveformType: 'stepped_frequency' },
279 { id: '6', name: 'CW File', waveformType: 'cw_from_file' },
280 { id: '7', name: 'FMCW File', waveformType: 'fmcw_from_file' },
281 ];
282
283 test('offers FMCW radar mode and hides pulsed-only fields for FMCW/CW', () => {
284 expect(RADAR_MODE_OPTIONS).toEqual([
285 { value: 'pulsed', label: 'Pulsed' },
286 { value: 'cw', label: 'CW' },
287 { value: 'fmcw', label: 'FMCW' },
288 { value: 'sfcw', label: 'SFCW' },
289 ]);
290
291 expect(getPulsedRadarFieldLabels('pulsed')).toEqual([
292 'PRF (Hz)',
293 'Window Skip (s)',
294 'Window Length (s)',
295 ]);
296 expect(getPulsedRadarFieldLabels('cw')).toEqual([]);
297 expect(getPulsedRadarFieldLabels('fmcw')).toEqual([]);
298 expect(getPulsedRadarFieldLabels('sfcw')).toEqual([]);
299 });
300
301 test('filters waveform dropdown choices by radar mode', () => {
302 expect(getCompatibleWaveforms(waveforms, 'pulsed')).toEqual([
303 waveforms[0],
304 ]);
305 expect(getCompatibleWaveforms(waveforms, 'cw')).toEqual([
306 waveforms[1],
307 waveforms[5],
308 ]);
309 expect(getCompatibleWaveforms(waveforms, 'fmcw')).toEqual([
310 waveforms[2],
311 waveforms[3],
312 waveforms[6],
313 ]);
314 expect(getCompatibleWaveforms(waveforms, 'sfcw')).toEqual([
315 waveforms[4],
316 ]);
317 });
318
319 test('clears and blocks incompatible waveform selection values', () => {
320 expect(shouldClearWaveformForRadarType('1', waveforms, 'fmcw')).toBe(
321 true
322 );
323 expect(shouldClearWaveformForRadarType('3', waveforms, 'fmcw')).toBe(
324 false
325 );
326 expect(shouldClearWaveformForRadarType('4', waveforms, 'fmcw')).toBe(
327 false
328 );
329 expect(resolveWaveformSelectValue('1', waveforms, 'fmcw')).toBe('');
330 expect(resolveWaveformSelectValue('3', waveforms, 'fmcw')).toBe('3');
331 expect(resolveWaveformSelectValue('4', waveforms, 'fmcw')).toBe('4');
332 expect(shouldClearWaveformForRadarType('3', waveforms, 'sfcw')).toBe(
333 true
334 );
335 expect(shouldClearWaveformForRadarType('5', waveforms, 'sfcw')).toBe(
336 false
337 );
338 expect(resolveWaveformSelectValue('5', waveforms, 'sfcw')).toBe('5');
339 });
340
341 test('offers dechirp modes and source options for receiver FMCW mode', () => {
342 expect(DECHIRP_MODE_OPTIONS).toEqual([
343 { value: 'none', label: 'None' },
344 { value: 'physical', label: 'Physical' },
345 { value: 'ideal', label: 'Ideal' },
346 ]);
347 expect(DECHIRP_REFERENCE_SOURCE_OPTIONS).toEqual([
348 { value: 'attached', label: 'Attached' },
349 { value: 'transmitter', label: 'Transmitter' },
350 { value: 'custom', label: 'Custom Waveform' },
351 ]);
352 expect(getAvailableDechirpReferenceSourceOptions('monostatic')).toEqual(
353 [...DECHIRP_REFERENCE_SOURCE_OPTIONS]
354 );
355 expect(getAvailableDechirpReferenceSourceOptions('receiver')).toEqual([
356 { value: 'transmitter', label: 'Transmitter' },
357 { value: 'custom', label: 'Custom Waveform' },
358 ]);
359 expect(
360 getAvailableDechirpReferenceSourceOptions('receiver', 'attached')
361 ).toEqual([...DECHIRP_REFERENCE_SOURCE_OPTIONS]);
362 });
363
364 test('builds backend-valid dechirp config shapes', () => {
365 expect(createFmcwModeConfig('none')).toEqual({});
366 expect(createFmcwModeConfig('physical')).toEqual({
367 dechirp_mode: 'physical',
368 dechirp_reference: { source: 'attached' },
369 });
370 expect(
371 createFmcwModeConfig('ideal', {
372 dechirp_mode: 'physical',
373 dechirp_reference: {
374 source: 'transmitter',
375 transmitter_name: 'TX A',
376 },
377 })
378 ).toEqual({
379 dechirp_mode: 'ideal',
380 dechirp_reference: {
381 source: 'transmitter',
382 transmitter_name: 'TX A',
383 },
384 });
385 expect(
386 createFmcwModeConfig('ideal', {
387 dechirp_mode: 'physical',
388 dechirp_reference: { source: 'attached' },
389 if_sample_rate: 1e6,
390 if_filter_bandwidth: 4e5,
391 if_filter_transition_width: 1e5,
392 })
393 ).toEqual({
394 dechirp_mode: 'ideal',
395 dechirp_reference: { source: 'attached' },
396 if_sample_rate: 1e6,
397 if_filter_bandwidth: 4e5,
398 if_filter_transition_width: 1e5,
399 });
400 expect(
401 createDechirpReference('transmitter', {
402 source: 'custom',
403 waveform_name: 'Bad carryover',
404 transmitter_name: 'TX B',
405 })
406 ).toEqual({
407 source: 'transmitter',
408 transmitter_name: 'TX B',
409 });
410 expect(
411 createDechirpReference('custom', {
412 source: 'transmitter',
413 transmitter_name: 'Bad carryover',
414 waveform_name: 'LO Waveform',
415 })
416 ).toEqual({
417 source: 'custom',
418 waveform_name: 'LO Waveform',
419 });
420 });
421
422 test('lists only FMCW waveforms and FMCW emitters for dechirp references', () => {
423 const fullWaveforms = [
424 {
425 id: '1',
426 type: 'Waveform' as const,
427 name: 'Pulse',
428 waveformType: 'pulsed_from_file' as const,
429 power: 1,
430 carrier_frequency: 1,
431 filename: 'pulse.h5',
432 },
433 {
434 id: '2',
435 type: 'Waveform' as const,
436 name: 'FMCW LO',
437 waveformType: 'fmcw_linear_chirp' as const,
438 direction: 'up' as const,
439 power: 1,
440 carrier_frequency: 1,
441 chirp_bandwidth: 10,
442 chirp_duration: 1,
443 chirp_period: 1,
444 start_frequency_offset: 0,
445 chirp_count: null,
446 },
447 {
448 id: '3',
449 type: 'Waveform' as const,
450 name: 'FMCW File LO',
451 waveformType: 'fmcw_from_file' as const,
452 power: 1,
453 carrier_frequency: 1,
454 filename: 'fmcw.h5',
455 },
456 ];
457 const platforms = [
458 {
459 id: 'p1',
460 type: 'Platform' as const,
461 name: 'Platform',
462 motionPath: {
463 interpolation: 'static' as const,
464 waypoints: [
465 {
466 id: 'wp1',
467 x: 0,
468 y: 0,
469 altitude: 0,
470 time: 0,
471 },
472 ],
473 },
474 rotation: {
475 type: 'fixed' as const,
476 startAzimuth: 0,
477 startElevation: 0,
478 azimuthRate: 0,
479 elevationRate: 0,
480 },
481 components: [
482 {
483 id: 'tx1',
484 type: 'transmitter' as const,
485 name: 'FMCW TX',
486 radarType: 'fmcw' as const,
487 prf: null,
488 antennaId: null,
489 waveformId: '2',
490 timingId: null,
491 schedule: [],
492 },
493 {
494 id: 'tx2',
495 type: 'transmitter' as const,
496 name: 'Pulse TX',
497 radarType: 'pulsed' as const,
498 prf: 1000,
499 antennaId: null,
500 waveformId: '1',
501 timingId: null,
502 schedule: [],
503 },
504 ],
505 },
506 ];
507
508 expect(getFmcwWaveformNames(fullWaveforms)).toEqual([
509 'FMCW LO',
510 'FMCW File LO',
511 ]);
512 expect(getFmcwEmitterNames(platforms, fullWaveforms)).toEqual([
513 'FMCW TX',
514 ]);
515 });
516});