Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-slots/slotStrips.test.ts
T
Дмитрий Соловьев b092e50dd8 feat(tgu-ui): выбор полосы слотов мышью «от первого к последнему»
Замена авто-чипа «полоса ×N» на ручной жест. Полоса переопределена:
один КА + один крен (ε) + подряд по времени; виток в границу не входит.

- slotStrips.ts: groupStrips → { idByKey, membersOf }; группировка по КА →
  кластеры по крену (ROLL_EPSILON=0.5) → деление по каденции (CADENCE_GAP_FACTOR=1.5)
- slotSelection.ts: selectStripRange вместо toggleStripSelection
- TguSlotsTab.tsx: клик по строке = якорь + выбор; Shift+клик тянет полосу
  между якорем и строкой; выход за полосу запрещён + подсказка; рельс полосы якоря
- tgu-planning.css: убраны стили чипа/hover; user-select:none на строках
- тесты обновлены (125 зелёных), tsc/build чисто
2026-06-17 10:13:11 +03:00

130 lines
5.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { ROLL_EPSILON, groupStrips, type StripGrouping } from "./slotStrips";
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
type S = {
satelliteId: number;
slotNumber: number;
cycle: number;
tn: string;
tk: string;
roll: number;
};
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. cycle ≈ «виток» (в границу не входит). */
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
return { satelliteId, slotNumber, cycle, tn, tk, roll };
}
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */
function partition(g: StripGrouping): string[][] {
const byStrip = new Map<string, string[]>();
for (const [key, strip] of g.idByKey) {
const arr = byStrip.get(strip) ?? [];
arr.push(key);
byStrip.set(strip, arr);
}
return [...byStrip.values()].map((keys) => keys.slice().sort()).sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
}
describe("groupStrips", () => {
it("пустой вход → пустая разметка", () => {
const g = groupStrips([], keyOf);
expect(g.idByKey.size).toBe(0);
expect(g.membersOf.size).toBe(0);
});
it("один слот → своя полоса", () => {
const g = groupStrips([slot(1, 1, 5, 0)], keyOf);
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1"]);
});
it("веер кренов в один момент → разные полосы; равный крен вдоль времени → одна полоса", () => {
// КА 1: два нацеливания (крен 36.5 и 39.5, шаг 3° > ε), каждое снято в моменты mm=0 и mm=1.
const slots = [
slot(1, 1, 36.5, 0),
slot(1, 2, 39.5, 0),
slot(1, 3, 36.5, 1),
slot(1, 4, 39.5, 1),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:3:1"], // крен 36.5 вдоль времени
["1:2:1", "1:4:1"], // крен 39.5 вдоль времени
]);
});
it("смена витка (cycle) внутри одного крена и каденции НЕ рвёт полосу", () => {
const slots = [
slot(1, 1, 5, 0, 1),
slot(1, 2, 5, 1, 1),
slot(1, 3, 5, 2, 2), // виток сменился у экватора — но это та же полоса
slot(1, 4, 5, 3, 2),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1", "1:3:2", "1:4:2"],
]);
});
it("большой разрыв по времени рвёт полосу на две (другой проход)", () => {
const slots = [
slot(1, 1, 5, 0),
slot(1, 2, 5, 1),
slot(1, 3, 5, 2),
slot(1, 4, 5, 3),
slot(1, 5, 5, 30), // разрыв ≫ каденции (1 мин) → новый проход
slot(1, 6, 5, 31),
slot(1, 7, 5, 32),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1", "1:3:1", "1:4:1"],
["1:5:1", "1:6:1", "1:7:1"],
]);
});
it("разные КА — разные полосы (даже при одном крене и времени)", () => {
const slots = [slot(1, 1, 5, 0), slot(2, 1, 5, 0), slot(1, 2, 5, 1), slot(2, 2, 5, 1)];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1"],
["2:1:1", "2:2:1"],
]);
});
it("граница ε по крену: разрыв == 0.5 склеивает, 0.6 рвёт", () => {
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.1, 1)], keyOf);
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
});
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)];
const a = partition(groupStrips(slots, keyOf));
const b = partition(groupStrips([...slots].reverse(), keyOf));
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
expect(b).toEqual(a);
expect(c).toEqual(a);
});
it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => {
const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]);
});
it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => {
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
const g = groupStrips(slots, keyOf);
expect(g.idByKey.get("1:9:1")).toBe("polosa:1:9:1");
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
});
});