c402f376c5
Степпер прохождений (виток как проход над районом): - slotPasses.ts: группировка слотов в проходы (КА·cycle·revolution·sign) в хронологии по всем КА + юнит-тесты; - PassStepper.tsx: ◀/▶ листают проходы, окно суток следует за курсором, таблица скроллится к проходу, карта не прыгает; выбранный проход изолирует отображение, «Все» — назад ко всему набору; - дата/время прохождения отдельной строкой во всю ширину (видно целиком). Подсветка конфликтов выбора (мягкая подсказка «станет недоступным»): - slotConflicts.ts: тот же КА + недостаточный зазор на манёвр — 10 с при том же крене, 30 с при смене крена (ROLL_EPSILON); конфликтные слоты гаснут в таблице и на карте, остаются выбираемыми (жёсткая выполнимость — за бэком); - производительность: оконный поиск по времени (~O(N·logM)), кандидаты — только видимые, выбор через useDeferredValue (не молотит во время drag); - чекбокс «Гасить конфликты» в строке выбора: выключает и слой, и расчёт; - юнит-тесты на правило зазора и оконный поиск. Карта: проп dimmedObjectIds — серый «налёт» поверх конфликтных слотов в SVG overlay (без изменения canvas).
91 lines
2.9 KiB
TypeScript
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"]);
|
|
});
|
|
});
|