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 pulse file, CW, and FMCW waveform types', () => {
100 expect(WAVEFORM_TYPE_OPTIONS).toEqual([
101 { value: 'pulsed_from_file', label: 'Pulse File' },
102 { value: 'cw', label: 'CW' },
103 { value: 'fmcw_linear_chirp', label: 'FMCW Linear Chirp' },
104 { value: 'fmcw_triangle', label: 'FMCW Triangle' },
105 ]);
106 });
107
108 test('creates FMCW defaults while preserving common waveform fields', () => {
109 const fmcwWaveform = createWaveformForType(
110 {
111 id: '1',
112 type: 'Waveform',
113 name: 'Search chirp',
114 waveformType: 'cw',
115 power: 250,
116 carrier_frequency: 9.6e9,
117 chirp_count: 4.8,
118 start_frequency_offset: 125,
119 },
120 'fmcw_linear_chirp'
121 );
122
123 expect(fmcwWaveform).toMatchObject({
124 id: '1',
125 type: 'Waveform',
126 name: 'Search chirp',
127 waveformType: 'fmcw_linear_chirp',
128 direction: 'up',
129 power: 250,
130 carrier_frequency: 9.6e9,
131 chirp_bandwidth: 4e3,
132 chirp_duration: 1e-3,
133 chirp_period: 1e-3,
134 start_frequency_offset: 125,
135 chirp_count: 4,
136 });
137 });
138
139 test('reports FMCW chirp fields only for FMCW waveforms', () => {
140 expect(getVisibleWaveformFieldLabels('fmcw_linear_chirp')).toEqual([
141 'Direction',
142 'Chirp Bandwidth (Hz)',
143 'Chirp Duration (s)',
144 'Chirp Period (s)',
145 'Start Frequency Offset (Hz)',
146 'Chirp Count',
147 ]);
148 expect(getVisibleWaveformFieldLabels('fmcw_triangle')).toEqual([
149 'Chirp Bandwidth (Hz)',
150 'Chirp Duration (s)',
151 'Start Frequency Offset (Hz)',
152 'Triangle Count',
153 ]);
154 expect(getVisibleWaveformFieldLabels('cw')).toEqual([]);
155 });
156});
157
158describe('Platform inspector authoring helpers', () => {
159 test('adds a second position waypoint before selecting cubic interpolation', () => {
160 const waypoints = [
161 {
162 id: 'position-1',
163 x: 1,
164 y: 2,
165 altitude: 3,
166 time: 4,
167 },
168 ];
169
170 const next = ensureCubicPositionWaypoints(waypoints);
171
172 expect(next).toHaveLength(2);
173 expect(next[0]).toEqual(waypoints[0]);
174 expect(next[1]).toMatchObject({
175 x: 1,
176 y: 2,
177 altitude: 3,
178 time: 5,
179 });
180 expect(next[1].id).not.toBe('position-1');
181 });
182
183 test('adds a second rotation waypoint before selecting cubic interpolation', () => {
184 const waypoints = [
185 {
186 id: 'rotation-1',
187 azimuth: 10,
188 elevation: 20,
189 time: 4,
190 },
191 ];
192
193 const next = ensureCubicRotationWaypoints(waypoints);
194
195 expect(next).toHaveLength(2);
196 expect(next[0]).toEqual(waypoints[0]);
197 expect(next[1]).toMatchObject({
198 azimuth: 10,
199 elevation: 20,
200 time: 5,
201 });
202 expect(next[1].id).not.toBe('rotation-1');
203 });
204});
205
206describe('Platform component inspector waveform compatibility', () => {
207 const waveforms = [
208 { id: '1', name: 'Pulse', waveformType: 'pulsed_from_file' },
209 { id: '2', name: 'Tone', waveformType: 'cw' },
210 { id: '3', name: 'Chirp', waveformType: 'fmcw_linear_chirp' },
211 { id: '4', name: 'Triangle', waveformType: 'fmcw_triangle' },
212 ];
213
214 test('offers FMCW radar mode and hides pulsed-only fields for FMCW/CW', () => {
215 expect(RADAR_MODE_OPTIONS).toEqual([
216 { value: 'pulsed', label: 'Pulsed' },
217 { value: 'cw', label: 'CW' },
218 { value: 'fmcw', label: 'FMCW' },
219 ]);
220
221 expect(getPulsedRadarFieldLabels('pulsed')).toEqual([
222 'PRF (Hz)',
223 'Window Skip (s)',
224 'Window Length (s)',
225 ]);
226 expect(getPulsedRadarFieldLabels('cw')).toEqual([]);
227 expect(getPulsedRadarFieldLabels('fmcw')).toEqual([]);
228 });
229
230 test('filters waveform dropdown choices by radar mode', () => {
231 expect(getCompatibleWaveforms(waveforms, 'pulsed')).toEqual([
232 waveforms[0],
233 ]);
234 expect(getCompatibleWaveforms(waveforms, 'cw')).toEqual([waveforms[1]]);
235 expect(getCompatibleWaveforms(waveforms, 'fmcw')).toEqual([
236 waveforms[2],
237 waveforms[3],
238 ]);
239 });
240
241 test('clears and blocks incompatible waveform selection values', () => {
242 expect(shouldClearWaveformForRadarType('1', waveforms, 'fmcw')).toBe(
243 true
244 );
245 expect(shouldClearWaveformForRadarType('3', waveforms, 'fmcw')).toBe(
246 false
247 );
248 expect(shouldClearWaveformForRadarType('4', waveforms, 'fmcw')).toBe(
249 false
250 );
251 expect(resolveWaveformSelectValue('1', waveforms, 'fmcw')).toBe('');
252 expect(resolveWaveformSelectValue('3', waveforms, 'fmcw')).toBe('3');
253 expect(resolveWaveformSelectValue('4', waveforms, 'fmcw')).toBe('4');
254 });
255
256 test('offers dechirp modes and source options for receiver FMCW mode', () => {
257 expect(DECHIRP_MODE_OPTIONS).toEqual([
258 { value: 'none', label: 'None' },
259 { value: 'physical', label: 'Physical' },
260 { value: 'ideal', label: 'Ideal' },
261 ]);
262 expect(DECHIRP_REFERENCE_SOURCE_OPTIONS).toEqual([
263 { value: 'attached', label: 'Attached' },
264 { value: 'transmitter', label: 'Transmitter' },
265 { value: 'custom', label: 'Custom Waveform' },
266 ]);
267 expect(getAvailableDechirpReferenceSourceOptions('monostatic')).toEqual(
268 [...DECHIRP_REFERENCE_SOURCE_OPTIONS]
269 );
270 expect(getAvailableDechirpReferenceSourceOptions('receiver')).toEqual([
271 { value: 'transmitter', label: 'Transmitter' },
272 { value: 'custom', label: 'Custom Waveform' },
273 ]);
274 expect(
275 getAvailableDechirpReferenceSourceOptions('receiver', 'attached')
276 ).toEqual([...DECHIRP_REFERENCE_SOURCE_OPTIONS]);
277 });
278
279 test('builds backend-valid dechirp config shapes', () => {
280 expect(createFmcwModeConfig('none')).toEqual({});
281 expect(createFmcwModeConfig('physical')).toEqual({
282 dechirp_mode: 'physical',
283 dechirp_reference: { source: 'attached' },
284 });
285 expect(
286 createFmcwModeConfig('ideal', {
287 dechirp_mode: 'physical',
288 dechirp_reference: {
289 source: 'transmitter',
290 transmitter_name: 'TX A',
291 },
292 })
293 ).toEqual({
294 dechirp_mode: 'ideal',
295 dechirp_reference: {
296 source: 'transmitter',
297 transmitter_name: 'TX A',
298 },
299 });
300 expect(
301 createFmcwModeConfig('ideal', {
302 dechirp_mode: 'physical',
303 dechirp_reference: { source: 'attached' },
304 if_sample_rate: 1e6,
305 if_filter_bandwidth: 4e5,
306 if_filter_transition_width: 1e5,
307 })
308 ).toEqual({
309 dechirp_mode: 'ideal',
310 dechirp_reference: { source: 'attached' },
311 if_sample_rate: 1e6,
312 if_filter_bandwidth: 4e5,
313 if_filter_transition_width: 1e5,
314 });
315 expect(
316 createDechirpReference('transmitter', {
317 source: 'custom',
318 waveform_name: 'Bad carryover',
319 transmitter_name: 'TX B',
320 })
321 ).toEqual({
322 source: 'transmitter',
323 transmitter_name: 'TX B',
324 });
325 expect(
326 createDechirpReference('custom', {
327 source: 'transmitter',
328 transmitter_name: 'Bad carryover',
329 waveform_name: 'LO Waveform',
330 })
331 ).toEqual({
332 source: 'custom',
333 waveform_name: 'LO Waveform',
334 });
335 });
336
337 test('lists only FMCW waveforms and FMCW emitters for dechirp references', () => {
338 const fullWaveforms = [
339 {
340 id: '1',
341 type: 'Waveform' as const,
342 name: 'Pulse',
343 waveformType: 'pulsed_from_file' as const,
344 power: 1,
345 carrier_frequency: 1,
346 filename: 'pulse.h5',
347 },
348 {
349 id: '2',
350 type: 'Waveform' as const,
351 name: 'FMCW LO',
352 waveformType: 'fmcw_linear_chirp' as const,
353 direction: 'up' as const,
354 power: 1,
355 carrier_frequency: 1,
356 chirp_bandwidth: 10,
357 chirp_duration: 1,
358 chirp_period: 1,
359 start_frequency_offset: 0,
360 chirp_count: null,
361 },
362 ];
363 const platforms = [
364 {
365 id: 'p1',
366 type: 'Platform' as const,
367 name: 'Platform',
368 motionPath: {
369 interpolation: 'static' as const,
370 waypoints: [
371 {
372 id: 'wp1',
373 x: 0,
374 y: 0,
375 altitude: 0,
376 time: 0,
377 },
378 ],
379 },
380 rotation: {
381 type: 'fixed' as const,
382 startAzimuth: 0,
383 startElevation: 0,
384 azimuthRate: 0,
385 elevationRate: 0,
386 },
387 components: [
388 {
389 id: 'tx1',
390 type: 'transmitter' as const,
391 name: 'FMCW TX',
392 radarType: 'fmcw' as const,
393 prf: null,
394 antennaId: null,
395 waveformId: '2',
396 timingId: null,
397 schedule: [],
398 },
399 {
400 id: 'tx2',
401 type: 'transmitter' as const,
402 name: 'Pulse TX',
403 radarType: 'pulsed' as const,
404 prf: 1000,
405 antennaId: null,
406 waveformId: '1',
407 timingId: null,
408 schedule: [],
409 },
410 ],
411 },
412 ];
413
414 expect(getFmcwWaveformNames(fullWaveforms)).toEqual(['FMCW LO']);
415 expect(getFmcwEmitterNames(platforms, fullWaveforms)).toEqual([
416 'FMCW TX',
417 ]);
418 });
419});