FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
assetTemplates.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 cloneTemplateIntoScenarioData,
7 createAssetLibraryFile,
8 createTemplateFromScenarioItem,
9 parseAssetTemplates,
10 prepareTemplatesForCatalog,
11} from './assetTemplates';
12import { defaultGlobalParameters } from './scenarioStore/defaults';
13import type { ScenarioData } from './scenarioStore/types';
14
15function createScenarioFixture(): ScenarioData {
16 return {
17 globalParameters: {
18 ...defaultGlobalParameters,
19 simulation_name: 'Library Fixture',
20 },
21 waveforms: [
22 {
23 id: '6001',
24 type: 'Waveform',
25 name: 'Pulse A',
26 waveformType: 'pulsed_from_file',
27 power: 100,
28 carrier_frequency: 1e9,
29 filename: '/tmp/pulse.h5',
30 },
31 ],
32 timings: [
33 {
34 id: '7001',
35 type: 'Timing',
36 name: 'Timing A',
37 frequency: 10e6,
38 freqOffset: null,
39 randomFreqOffsetStdev: null,
40 phaseOffset: null,
41 randomPhaseOffsetStdev: null,
42 noiseEntries: [{ id: '7002', alpha: 0.2, weight: 0.5 }],
43 },
44 ],
45 antennas: [
46 {
47 id: '5001',
48 type: 'Antenna',
49 name: 'Yagi A',
50 pattern: 'sinc',
51 efficiency: 0.9,
52 meshScale: 1,
53 design_frequency: 1e9,
54 alpha: 1,
55 beta: 2,
56 gamma: 3,
57 },
58 ],
59 platforms: [
60 {
61 id: '1001',
62 type: 'Platform',
63 name: 'Aircraft A',
64 motionPath: {
65 interpolation: 'static',
66 waypoints: [
67 {
68 id: '1002',
69 x: 0,
70 y: 0,
71 altitude: 1000,
72 time: 0,
73 },
74 ],
75 },
76 rotation: {
77 type: 'path',
78 interpolation: 'static',
79 waypoints: [
80 {
81 id: '1003',
82 azimuth: 0,
83 elevation: 0,
84 time: 0,
85 },
86 ],
87 },
88 components: [
89 {
90 id: '2001',
91 type: 'transmitter',
92 name: 'Tx A',
93 radarType: 'pulsed',
94 prf: 1000,
95 antennaId: '5001',
96 waveformId: '6001',
97 timingId: '7001',
98 schedule: [],
99 },
100 ],
101 pathPoints: [
102 {
103 x: 0,
104 y: 0,
105 z: 0,
106 vx: 0,
107 vy: 0,
108 vz: 0,
109 },
110 ],
111 rotationPathPoints: [{ azimuth: 0, elevation: 0 }],
112 },
113 ],
114 };
115}
116
117function createFmcwScenarioFixture(): ScenarioData {
118 const scenario = createScenarioFixture();
119 return {
120 ...scenario,
121 waveforms: [
122 {
123 id: '6002',
124 type: 'Waveform',
125 name: 'FMCW Up Chirp',
126 waveformType: 'fmcw_linear_chirp',
127 direction: 'up',
128 power: 50,
129 carrier_frequency: 10e9,
130 chirp_bandwidth: 20e6,
131 chirp_duration: 250e-6,
132 chirp_period: 500e-6,
133 start_frequency_offset: 1e6,
134 chirp_count: 8,
135 },
136 ],
137 platforms: [
138 {
139 ...scenario.platforms[0],
140 components: [
141 {
142 id: '2101',
143 type: 'transmitter',
144 name: 'FMCW Tx',
145 radarType: 'fmcw',
146 prf: null,
147 antennaId: '5001',
148 waveformId: '6002',
149 timingId: '7001',
150 schedule: [{ start: 0, end: 0.01 }],
151 },
152 {
153 id: '2102',
154 type: 'receiver',
155 name: 'FMCW Rx',
156 radarType: 'fmcw',
157 window_skip: null,
158 window_length: null,
159 prf: null,
160 antennaId: '5001',
161 timingId: '7001',
162 noiseTemperature: 290,
163 noDirectPaths: false,
164 noPropagationLoss: false,
165 schedule: [{ start: 0, end: 0.01 }],
166 },
167 {
168 id: '2103',
169 type: 'monostatic',
170 name: 'FMCW Monostatic',
171 txId: '2104',
172 rxId: '2105',
173 radarType: 'fmcw',
174 window_skip: null,
175 window_length: null,
176 prf: null,
177 antennaId: '5001',
178 waveformId: '6002',
179 timingId: '7001',
180 noiseTemperature: 290,
181 noDirectPaths: false,
182 noPropagationLoss: false,
183 schedule: [{ start: 0, end: 0.01 }],
184 },
185 ],
186 },
187 ],
188 };
189}
190
191describe('asset templates', () => {
192 test('creates top-level asset snapshots', () => {
193 const scenario = createScenarioFixture();
194 const template = createTemplateFromScenarioItem(scenario, '5001', {
195 id: 'template-1',
196 timestamp: '2026-04-13T00:00:00.000Z',
197 });
198
199 expect(template).toMatchObject({
200 id: 'template-1',
201 kind: 'antenna',
202 name: 'Yagi A',
203 payload: {
204 id: '5001',
205 pattern: 'sinc',
206 },
207 });
208 });
209
210 test('captures platform dependencies and strips runtime path caches', () => {
211 const scenario = createScenarioFixture();
212 const template = createTemplateFromScenarioItem(scenario, '1001', {
213 id: 'template-2',
214 timestamp: '2026-04-13T00:00:00.000Z',
215 });
216
217 expect(template?.kind).toBe('platform');
218 if (template?.kind !== 'platform') {
219 throw new Error('Expected platform template.');
220 }
221
222 expect(template.dependencies.antennas).toHaveLength(1);
223 expect(template.dependencies.waveforms).toHaveLength(1);
224 expect(template.dependencies.timings).toHaveLength(1);
225 expect(template.payload).not.toHaveProperty('pathPoints');
226 expect(template.payload).not.toHaveProperty('rotationPathPoints');
227 });
228
229 test('loads platform templates with fresh IDs and remapped dependencies', () => {
230 const scenario = createScenarioFixture();
231 const template = createTemplateFromScenarioItem(scenario, '1001');
232 if (template?.kind !== 'platform') {
233 throw new Error('Expected platform template.');
234 }
235
236 const { scenarioData, result } = cloneTemplateIntoScenarioData(
237 scenario,
238 template
239 );
240 const insertedPlatform = scenarioData.platforms.at(-1);
241 const insertedComponent = insertedPlatform?.components[0];
242
243 expect(result.warnings).toEqual([]);
244 expect(insertedPlatform?.id).not.toBe('1001');
245 expect(insertedPlatform?.name).toBe('Aircraft A Copy');
246 expect(insertedComponent?.id).not.toBe('2001');
247 expect(insertedComponent?.type).toBe('transmitter');
248 if (insertedComponent?.type !== 'transmitter') {
249 throw new Error('Expected transmitter component.');
250 }
251 expect(insertedComponent.name).toBe('Tx A Copy');
252 expect(insertedComponent.antennaId).not.toBe('5001');
253 expect(insertedComponent.waveformId).not.toBe('6001');
254 expect(insertedComponent.timingId).not.toBe('7001');
255 expect(
256 scenarioData.antennas.some(
257 (antenna) => antenna.id === insertedComponent.antennaId
258 )
259 ).toBe(true);
260 expect(
261 scenarioData.waveforms.some(
262 (waveform) => waveform.id === insertedComponent.waveformId
263 )
264 ).toBe(true);
265 expect(
266 scenarioData.timings.some(
267 (timing) => timing.id === insertedComponent.timingId
268 )
269 ).toBe(true);
270 });
271
272 test('uses template display names when loading renamed assets', () => {
273 const scenario = createScenarioFixture();
274 const template = createTemplateFromScenarioItem(scenario, '6001');
275 if (template?.kind !== 'waveform') {
276 throw new Error('Expected waveform template.');
277 }
278
279 const { scenarioData, result } = cloneTemplateIntoScenarioData(
280 scenario,
281 {
282 ...template,
283 name: 'Reusable Pulse',
284 }
285 );
286
287 expect(result.insertedName).toBe('Reusable Pulse Copy');
288 expect(scenarioData.waveforms.at(-1)?.name).toBe('Reusable Pulse Copy');
289 });
290
291 test('clears missing platform dependency references with warnings', () => {
292 const scenario = createScenarioFixture();
293 const template = createTemplateFromScenarioItem(scenario, '1001');
294 if (template?.kind !== 'platform') {
295 throw new Error('Expected platform template.');
296 }
297 const templateWithoutDependencies = {
298 ...template,
299 dependencies: {
300 waveforms: [],
301 timings: [],
302 antennas: [],
303 },
304 };
305
306 const { scenarioData, result } = cloneTemplateIntoScenarioData(
307 scenario,
308 templateWithoutDependencies
309 );
310 const insertedComponent = scenarioData.platforms.at(-1)?.components[0];
311
312 expect(result.warnings).toHaveLength(3);
313 expect(insertedComponent?.type).toBe('transmitter');
314 if (insertedComponent?.type !== 'transmitter') {
315 throw new Error('Expected transmitter component.');
316 }
317 expect(insertedComponent.antennaId).toBeNull();
318 expect(insertedComponent.waveformId).toBeNull();
319 expect(insertedComponent.timingId).toBeNull();
320 });
321
322 test('parses catalog files and assigns imported templates new catalog IDs', () => {
323 const scenario = createScenarioFixture();
324 const template = createTemplateFromScenarioItem(scenario, '6001', {
325 id: 'template-original',
326 });
327 if (!template) {
328 throw new Error('Expected waveform template.');
329 }
330
331 const parsed = parseAssetTemplates(createAssetLibraryFile([template]));
332 const prepared = prepareTemplatesForCatalog(parsed);
333
334 expect(parsed).toHaveLength(1);
335 expect(prepared).toHaveLength(1);
336 expect(prepared[0].id).not.toBe('template-original');
337 expect(prepared[0].payload.id).toBe('6001');
338 });
339
340 test('round trips FMCW waveform templates', () => {
341 const scenario = createFmcwScenarioFixture();
342 const template = createTemplateFromScenarioItem(scenario, '6002', {
343 id: 'template-fmcw-waveform',
344 timestamp: '2026-04-13T00:00:00.000Z',
345 });
346 if (template?.kind !== 'waveform') {
347 throw new Error('Expected waveform template.');
348 }
349
350 const [parsed] = parseAssetTemplates(
351 createAssetLibraryFile([template])
352 );
353 const { scenarioData, result } = cloneTemplateIntoScenarioData(
354 scenario,
355 parsed
356 );
357 const insertedWaveform = scenarioData.waveforms.at(-1);
358
359 expect(result.warnings).toEqual([]);
360 expect(parsed.payload).toMatchObject({
361 waveformType: 'fmcw_linear_chirp',
362 direction: 'up',
363 chirp_bandwidth: 20e6,
364 chirp_duration: 250e-6,
365 chirp_period: 500e-6,
366 start_frequency_offset: 1e6,
367 chirp_count: 8,
368 });
369 expect(insertedWaveform).toMatchObject({
370 name: 'FMCW Up Chirp Copy',
371 waveformType: 'fmcw_linear_chirp',
372 direction: 'up',
373 chirp_bandwidth: 20e6,
374 chirp_duration: 250e-6,
375 chirp_period: 500e-6,
376 });
377 });
378
379 test('round trips platform templates with FMCW components', () => {
380 const scenario = createFmcwScenarioFixture();
381 const template = createTemplateFromScenarioItem(scenario, '1001');
382 if (template?.kind !== 'platform') {
383 throw new Error('Expected platform template.');
384 }
385
386 const [parsed] = parseAssetTemplates(
387 createAssetLibraryFile([template])
388 );
389 const { scenarioData, result } = cloneTemplateIntoScenarioData(
390 scenario,
391 parsed
392 );
393 const insertedPlatform = scenarioData.platforms.at(-1);
394
395 expect(result.warnings).toEqual([]);
396 expect(
397 insertedPlatform?.components.map((component) => component.type)
398 ).toEqual(['transmitter', 'receiver', 'monostatic']);
399 expect(
400 insertedPlatform?.components.map((component) => component.name)
401 ).toEqual(['FMCW Tx Copy', 'FMCW Rx Copy', 'FMCW Monostatic Copy']);
402 expect(
403 insertedPlatform?.components.map((component) =>
404 'radarType' in component ? component.radarType : null
405 )
406 ).toEqual(['fmcw', 'fmcw', 'fmcw']);
407 expect(
408 insertedPlatform?.components.every(
409 (component) =>
410 !('waveformId' in component) ||
411 component.waveformId !== '6002'
412 )
413 ).toBe(true);
414 });
415
416 test('collects FMCW waveform dependencies for platform templates', () => {
417 const scenario = createFmcwScenarioFixture();
418 const template = createTemplateFromScenarioItem(scenario, '1001');
419 if (template?.kind !== 'platform') {
420 throw new Error('Expected platform template.');
421 }
422
423 expect(template.dependencies.waveforms).toHaveLength(1);
424 expect(template.dependencies.waveforms[0]).toMatchObject({
425 id: '6002',
426 waveformType: 'fmcw_linear_chirp',
427 direction: 'up',
428 chirp_bandwidth: 20e6,
429 });
430 });
431});