stratoflights-web/tests/unit/profile.test.ts
2026-08-14 00:01:49 +09:00

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');
});
});