144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
standardStages,
|
|
floatStages,
|
|
validateProfile,
|
|
toV2Request,
|
|
stageSummary,
|
|
DEFAULT_FLIGHT_PARAMETERS
|
|
} from '$domain';
|
|
import type { ProfileStage } from '$domain';
|
|
|
|
const params = { ...DEFAULT_FLIGHT_PARAMETERS, ascent_rate: 5, descent_rate: 6, burst_altitude: 30000 };
|
|
|
|
describe('presets', () => {
|
|
it('expresses the standard flight as ascent → descent stages', () => {
|
|
const stages = standardStages(params);
|
|
expect(stages.map((s) => s.name)).toEqual(['ascent', 'descent']);
|
|
expect(stages[0].solver).toMatchObject({ type: 'constant_rate', rate: 5 });
|
|
expect(stages[0].advanceWhen[0]).toMatchObject({ type: 'altitude', op: '>=', limit: 30000 });
|
|
// Descent ends on the ground, not at an altitude.
|
|
expect(stages[1].solver).toMatchObject({ type: 'parachute_descent', sea_level_rate: 6 });
|
|
expect(stages[1].advanceWhen[0].type).toBe('terrain_contact');
|
|
});
|
|
|
|
it('floats by holding altitude on the wind solver', () => {
|
|
const stages = floatStages(params);
|
|
expect(stages[1].solver.type).toBe('wind');
|
|
expect(stages[1].advanceWhen[0].type).toBe('time');
|
|
});
|
|
});
|
|
|
|
describe('validateProfile', () => {
|
|
it('accepts the standard preset', () => {
|
|
expect(validateProfile(standardStages(params))).toEqual([]);
|
|
});
|
|
|
|
it('rejects an empty profile', () => {
|
|
expect(validateProfile([])).toEqual(['profileBuilder.errNoStages']);
|
|
});
|
|
|
|
it('rejects a stage that can never end', () => {
|
|
const stages = standardStages(params);
|
|
stages[0].advanceWhen = [];
|
|
expect(validateProfile(stages)).toContain('profileBuilder.errNoExit');
|
|
});
|
|
|
|
it('rejects a fallback pointing at a stage that does not exist', () => {
|
|
const stages = standardStages(params);
|
|
stages[0].abortIf = [
|
|
{ type: 'time', op: '>=', limit: 100, action: 'fallback', fallback: 'nowhere' }
|
|
];
|
|
expect(validateProfile(stages)).toContain('profileBuilder.errMissingFallback');
|
|
|
|
stages[0].abortIf[0].fallback = 'descent';
|
|
expect(validateProfile(stages)).not.toContain('profileBuilder.errMissingFallback');
|
|
});
|
|
|
|
it('rejects a scalar constraint missing its operator or limit', () => {
|
|
const stages = standardStages(params);
|
|
stages[0].advanceWhen = [{ type: 'altitude', action: 'stop' }];
|
|
expect(validateProfile(stages)).toContain('profileBuilder.errIncompleteConstraint');
|
|
});
|
|
|
|
it('rejects duplicate and blank stage names', () => {
|
|
const dup: ProfileStage[] = [...standardStages(params)];
|
|
dup[1] = { ...dup[1], name: 'ascent' };
|
|
expect(validateProfile(dup)).toContain('profileBuilder.errDuplicateName');
|
|
|
|
const blank = standardStages(params);
|
|
blank[0].name = ' ';
|
|
expect(validateProfile(blank)).toContain('profileBuilder.errUnnamedStage');
|
|
});
|
|
});
|
|
|
|
describe('toV2Request', () => {
|
|
it('maps stages to the predictor contract', () => {
|
|
const req = toV2Request(standardStages(params), params, '2026-01-01T12:00:00.000Z', {
|
|
source: 'gfs-0p50-3h'
|
|
});
|
|
expect(req.launch).toEqual({
|
|
time: '2026-01-01T12:00:00.000Z',
|
|
latitude: params.launch_latitude,
|
|
longitude: params.launch_longitude,
|
|
altitude: params.launch_altitude
|
|
});
|
|
expect(req.direction).toBe('forward');
|
|
expect(req.source).toBe('gfs-0p50-3h');
|
|
expect(req.profile[0].model).toEqual({ type: 'constant_rate', rate: 5, include_wind: true });
|
|
expect(req.profile[1].model).toEqual({
|
|
type: 'parachute_descent',
|
|
sea_level_rate: 6,
|
|
include_wind: true
|
|
});
|
|
});
|
|
|
|
it('folds advance and abort constraints into one list, keeping their actions', () => {
|
|
const stages = standardStages(params);
|
|
stages[0].abortIf = [
|
|
{ type: 'time', op: '>=', limit: 7200, action: 'fallback', fallback: 'descent' }
|
|
];
|
|
const req = toV2Request(stages, params, '2026-01-01T12:00:00.000Z');
|
|
expect(req.profile[0].constraints).toHaveLength(2);
|
|
expect(req.profile[0].constraints[1]).toEqual({
|
|
type: 'time',
|
|
op: '>=',
|
|
limit: 7200,
|
|
action: 'fallback',
|
|
fallback: 'descent'
|
|
});
|
|
});
|
|
|
|
it('omits absent optional fields rather than sending nulls', () => {
|
|
const req = toV2Request(standardStages(params), params, '2026-01-01T12:00:00.000Z');
|
|
expect(req.profile[1].constraints[0]).toEqual({ type: 'terrain_contact', action: 'stop' });
|
|
expect('source' in req).toBe(false);
|
|
});
|
|
|
|
it('maps curve points to piecewise segments', () => {
|
|
const stages: ProfileStage[] = [
|
|
{
|
|
name: 'ascent',
|
|
solver: {
|
|
type: 'piecewise',
|
|
include_wind: true,
|
|
segments: [{ order: 0, time_constraint: 0, alt_constraint: 0, rate: 5 }]
|
|
},
|
|
advanceWhen: [{ type: 'terrain_contact', action: 'stop' }],
|
|
abortIf: []
|
|
}
|
|
];
|
|
const req = toV2Request(stages, params, '2026-01-01T12:00:00.000Z');
|
|
expect(req.profile[0].model.segments).toEqual([
|
|
{ reference: 'profile_start', time: 0, altitude: 0, rate: 5 }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('stageSummary', () => {
|
|
it('summarises a stage in one line', () => {
|
|
const [ascent, descent] = standardStages(params);
|
|
expect(stageSummary(ascent)).toBe('ascent · 5 m/s → 30000 m');
|
|
expect(stageSummary(descent)).toBe('descent · 6 m/s → ground');
|
|
});
|
|
});
|