Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-slots/slotPasses.test.ts
T
Дмитрий Соловьев c402f376c5 feat(tgu-ui): степпер по прохождениям и подсветка конфликтов выбора слотов
Степпер прохождений (виток как проход над районом):
- slotPasses.ts: группировка слотов в проходы (КА·cycle·revolution·sign) в
  хронологии по всем КА + юнит-тесты;
- PassStepper.tsx: ◀/▶ листают проходы, окно суток следует за курсором,
  таблица скроллится к проходу, карта не прыгает; выбранный проход
  изолирует отображение, «Все» — назад ко всему набору;
- дата/время прохождения отдельной строкой во всю ширину (видно целиком).

Подсветка конфликтов выбора (мягкая подсказка «станет недоступным»):
- slotConflicts.ts: тот же КА + недостаточный зазор на манёвр — 10 с при том
  же крене, 30 с при смене крена (ROLL_EPSILON); конфликтные слоты гаснут в
  таблице и на карте, остаются выбираемыми (жёсткая выполнимость — за бэком);
- производительность: оконный поиск по времени (~O(N·logM)), кандидаты —
  только видимые, выбор через useDeferredValue (не молотит во время drag);
- чекбокс «Гасить конфликты» в строке выбора: выключает и слой, и расчёт;
- юнит-тесты на правило зазора и оконный поиск.

Карта: проп dimmedObjectIds — серый «налёт» поверх конфликтных слотов в SVG
overlay (без изменения canvas).
2026-06-17 13:31:28 +03:00

91 lines
2.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { RevolutionSign } from "../../model/slotTypes";
import { groupPasses } from "./slotPasses";
/** Минимальный слот для группировки в витки. */
type S = {
satelliteId: number;
slotNumber: number;
cycle: number;
revolution: number;
revolutionSign: RevolutionSign;
tn: string;
};
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm от базовой даты; виток задаётся (revolution, sign, cycle). */
function slot(
satelliteId: number,
slotNumber: number,
mm: number,
revolution: number,
revolutionSign: RevolutionSign = "ASC",
cycle = 1,
): S {
return {
satelliteId,
slotNumber,
cycle,
revolution,
revolutionSign,
tn: `2026-06-17T10:${String(mm).padStart(2, "0")}:00`,
};
}
describe("groupPasses", () => {
it("пустой вход → пустой список", () => {
expect(groupPasses([], keyOf)).toEqual([]);
});
it("веер кренов одного оборота → один виток (крен не дробит)", () => {
// Три нацеливания одного прохода КА 1, оборот 100, восх — все в один виток.
const passes = groupPasses(
[slot(1, 1, 0, 100), slot(1, 2, 0, 100), slot(1, 3, 1, 100)],
keyOf,
);
expect(passes).toHaveLength(1);
expect(passes[0].id).toBe("1:1:100:ASC");
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
expect(passes[0].satelliteId).toBe(1);
expect(passes[0].revolution).toBe(100);
});
it("разные обороты / ветви / КА → разные витки", () => {
const passes = groupPasses(
[
slot(1, 1, 0, 100, "ASC"),
slot(1, 2, 5, 100, "DESC"), // та же revolution, другая ветвь
slot(1, 3, 10, 101, "ASC"), // другой оборот
slot(2, 4, 2, 100, "ASC"), // другой КА
],
keyOf,
);
expect(passes.map((p) => p.id)).toEqual([
"1:1:100:ASC", // 10:00
"2:1:100:ASC", // 10:02
"1:1:100:DESC", // 10:05
"1:1:101:ASC", // 10:10
]);
});
it("витки упорядочены по началу прохода (хронология по всем КА)", () => {
const passes = groupPasses(
[slot(1, 1, 30, 100), slot(2, 2, 5, 200), slot(1, 3, 15, 101)],
keyOf,
);
expect(passes.map((p) => p.startMs)).toEqual(
[...passes.map((p) => p.startMs)].sort((a, b) => a - b),
);
expect(passes[0].satelliteId).toBe(2); // самый ранний (10:05)
});
it("ключи внутри витка отсортированы по времени", () => {
const passes = groupPasses(
[slot(1, 3, 2, 100), slot(1, 1, 0, 100), slot(1, 2, 1, 100)],
keyOf,
);
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
});
});