feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
6
mocks/data/points.json
Normal file
6
mocks/data/points.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
{ "id": 1, "name": "Якутск (аэропорт)", "lat": 62.0928, "lon": 129.7706, "alt": 99 },
|
||||
{ "id": 2, "name": "Тикси", "lat": 71.6347, "lon": 128.8661, "alt": 11 },
|
||||
{ "id": 3, "name": "Мирный", "lat": 62.5344, "lon": 113.9589, "alt": 346 },
|
||||
{ "id": 4, "name": "Нерюнгри", "lat": 56.6588, "lon": 124.7181, "alt": 750 }
|
||||
]
|
||||
22
mocks/data/scenarios.json
Normal file
22
mocks/data/scenarios.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[
|
||||
{
|
||||
"id": 101,
|
||||
"name": "Стандартный 30 км",
|
||||
"description": "Обычный полет со стандартным профилем",
|
||||
"prediction_mode": "single",
|
||||
"model": "gfs_0p25",
|
||||
"dataset": "",
|
||||
"flight_parameters": {
|
||||
"ascent_rate": 5.0,
|
||||
"burst_altitude": 30000.0,
|
||||
"dataset": "",
|
||||
"descent_rate": 5.0,
|
||||
"format": "json",
|
||||
"launch_altitude": 99.0,
|
||||
"launch_latitude": 62.0928,
|
||||
"launch_longitude": 129.7706,
|
||||
"profile": "standard_profile",
|
||||
"version": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
56
mocks/handlers/prediction.ts
Normal file
56
mocks/handlers/prediction.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { FlightParameters } from '../../src/lib/domain/scenario';
|
||||
|
||||
/**
|
||||
* Generate a fake prediction trajectory. We fake an ascent phase that climbs
|
||||
* to burst_altitude at ascent_rate, then a descent at descent_rate. The
|
||||
* ground track drifts roughly east as a function of altitude to loosely
|
||||
* mimic wind drift.
|
||||
*/
|
||||
export function fakePrediction(params: FlightParameters & { launch_datetime: string }) {
|
||||
const launch = new Date(params.launch_datetime);
|
||||
const ascentDurationSec = Math.round(
|
||||
(params.burst_altitude - params.launch_altitude) / params.ascent_rate,
|
||||
);
|
||||
const descentDurationSec = Math.round(params.burst_altitude / params.descent_rate);
|
||||
const stepSec = 30;
|
||||
|
||||
const ascent: unknown[] = [];
|
||||
for (let t = 0; t <= ascentDurationSec; t += stepSec) {
|
||||
const alt = params.launch_altitude + t * params.ascent_rate;
|
||||
ascent.push({
|
||||
altitude: alt,
|
||||
datetime: new Date(launch.getTime() + t * 1000).toISOString(),
|
||||
latitude: params.launch_latitude + t * 0.00002,
|
||||
longitude: params.launch_longitude + t * 0.00005,
|
||||
});
|
||||
}
|
||||
|
||||
const descent: unknown[] = [];
|
||||
const burstTime = launch.getTime() + ascentDurationSec * 1000;
|
||||
const burstLat = params.launch_latitude + ascentDurationSec * 0.00002;
|
||||
const burstLng = params.launch_longitude + ascentDurationSec * 0.00005;
|
||||
for (let t = 0; t <= descentDurationSec; t += stepSec) {
|
||||
const alt = Math.max(0, params.burst_altitude - t * params.descent_rate);
|
||||
descent.push({
|
||||
altitude: alt,
|
||||
datetime: new Date(burstTime + t * 1000).toISOString(),
|
||||
latitude: burstLat + t * 0.00001,
|
||||
longitude: burstLng + t * 0.00003,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
metadata: {
|
||||
start_datetime: new Date(launch.getTime() - 3600 * 1000).toISOString(),
|
||||
complete_datetime: new Date(
|
||||
burstTime + descentDurationSec * 1000,
|
||||
).toISOString(),
|
||||
},
|
||||
prediction: [
|
||||
{ stage: 'ascent', trajectory: ascent },
|
||||
{ stage: 'descent', trajectory: descent },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
200
mocks/vitePlugin.ts
Normal file
200
mocks/vitePlugin.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import type { Connect, Plugin } from 'vite';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fakePrediction } from './handlers/prediction';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, 'data');
|
||||
|
||||
/**
|
||||
* Dev-only mock for the Django REST API. Enable by setting
|
||||
* `VITE_USE_MOCK_API=true` when running `vite dev`.
|
||||
*
|
||||
* Covered endpoints (everything under /api/):
|
||||
* - GET/POST/PUT/DELETE /saved-points/…
|
||||
* - GET/POST/PUT/DELETE /saved-templates/…
|
||||
* - GET/POST/PUT/DELETE /saved-profiles/…
|
||||
* - POST /predictions/
|
||||
* - GET /session/, /whoami/, /csrf/
|
||||
* - POST /login/, /logout/
|
||||
*
|
||||
* State lives on disk so edits survive dev-server restarts.
|
||||
*/
|
||||
|
||||
interface MockSession {
|
||||
isAuthenticated: boolean;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
let session: MockSession = { isAuthenticated: true, username: 'demo-user' };
|
||||
|
||||
function loadArray<T>(file: string, fallback: T[]): T[] {
|
||||
const path = join(DATA_DIR, file);
|
||||
if (!existsSync(path)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as T[];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function saveArray<T>(file: string, items: T[]): void {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(join(DATA_DIR, file), JSON.stringify(items, null, '\t'));
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function cors(res: ServerResponse, req: IncomingMessage) {
|
||||
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRFToken');
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<unknown> {
|
||||
return await new Promise((resolve) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
req.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if (!raw) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function crudEndpoint<T extends { id: number }>(
|
||||
file: string,
|
||||
path: string,
|
||||
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
return async (req, res) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith(path)) return false;
|
||||
|
||||
const remainder = url.slice(path.length);
|
||||
const [idPart] = remainder.replace(/\/$/, '').split('?');
|
||||
const id = idPart ? Number(idPart) : null;
|
||||
|
||||
let items = loadArray<T>(file, []);
|
||||
|
||||
switch (req.method) {
|
||||
case 'GET':
|
||||
if (id == null || Number.isNaN(id)) {
|
||||
send(res, 200, items);
|
||||
} else {
|
||||
const item = items.find((i) => i.id === id);
|
||||
item ? send(res, 200, item) : send(res, 404, { detail: 'not found' });
|
||||
}
|
||||
return true;
|
||||
case 'POST': {
|
||||
const body = (await readBody(req)) as Partial<T>;
|
||||
const nextId = items.reduce((m, i) => Math.max(m, i.id), 0) + 1;
|
||||
const created = { ...body, id: nextId } as T;
|
||||
items = [...items, created];
|
||||
saveArray(file, items);
|
||||
send(res, 201, created);
|
||||
return true;
|
||||
}
|
||||
case 'PUT': {
|
||||
const body = (await readBody(req)) as T;
|
||||
items = items.map((i) => (i.id === (id ?? body.id) ? body : i));
|
||||
saveArray(file, items);
|
||||
send(res, 200, body);
|
||||
return true;
|
||||
}
|
||||
case 'DELETE':
|
||||
items = items.filter((i) => i.id !== id);
|
||||
saveArray(file, items);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mockApiPlugin(): Plugin {
|
||||
const handlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [
|
||||
crudEndpoint('points.json', '/api/saved-points/'),
|
||||
crudEndpoint('scenarios.json', '/api/saved-templates/'),
|
||||
crudEndpoint('profiles.json', '/api/saved-profiles/'),
|
||||
];
|
||||
|
||||
const middleware: Connect.NextHandleFunction = async (req, res, next) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith('/api/')) return next();
|
||||
|
||||
cors(res, req);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/csrf/')) {
|
||||
res.setHeader('Set-Cookie', 'csrftoken=mock-csrf; Path=/');
|
||||
send(res, 200, { detail: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/session/')) {
|
||||
send(res, 200, { isAuthenticated: session.isAuthenticated });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/whoami/')) {
|
||||
if (!session.username) return send(res, 401, { detail: 'not authenticated' });
|
||||
send(res, 200, { username: session.username });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/login/')) {
|
||||
const body = (await readBody(req)) as { username: string; password: string };
|
||||
if (!body.username || !body.password) {
|
||||
return send(res, 400, { detail: 'missing credentials' });
|
||||
}
|
||||
session = { isAuthenticated: true, username: body.username };
|
||||
send(res, 200, { detail: 'ok', username: body.username });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/logout/')) {
|
||||
session = { isAuthenticated: false, username: null };
|
||||
send(res, 200, { detail: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/predictions/')) {
|
||||
const body = (await readBody(req)) as Parameters<typeof fakePrediction>[0];
|
||||
send(res, 200, fakePrediction(body));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const h of handlers) {
|
||||
if (await h(req, res)) return;
|
||||
}
|
||||
|
||||
send(res, 404, { detail: 'mock: endpoint not implemented' });
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'lsv-mock-api',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(middleware);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue